From bde8caf8eba71ce127d0a0b694f02c1491846001 Mon Sep 17 00:00:00 2001 From: EricMa <307748790@qq.com> Date: Sat, 8 Aug 2026 03:58:28 +0800 Subject: [PATCH 01/10] network: add ipvlan and macvlan direct L2 modes --- Android/app/build.gradle.kts | 6 + .../app/src/main/assets/post_extract_fixes.sh | 24 +- .../app/ui/component/ContainerCard.kt | 2 +- .../app/ui/component/ContainerConfigForm.kt | 160 ++++++++++++- .../app/ui/screen/ContainerConfigScreen.kt | 4 +- .../app/ui/screen/EditContainerScreen.kt | 4 +- .../ui/screen/InstallationSummaryScreen.kt | 10 +- .../app/ui/viewmodel/AppStateViewModel.kt | 10 +- .../droidspaces/app/util/BinaryInstaller.kt | 67 +++++- .../app/util/ContainerConfigState.kt | 49 ++++ .../droidspaces/app/util/ContainerManager.kt | 19 ++ .../src/main/res/values-zh-rCN/strings.xml | 21 ++ Android/app/src/main/res/values/strings.xml | 21 ++ src/boot.c | 72 ++++++ src/config.c | 71 ++++++ src/container.c | 32 +++ src/documentation.c | 11 +- src/include/droidspace.h | 27 ++- src/include/socketd_protocol.h | 4 +- src/main.c | 122 +++++++++- src/monitor.c | 8 + src/net/netlink.c | 83 +++++++ src/net/network.c | 216 +++++++++++++++++- src/socketd_bridge.c | 22 +- 24 files changed, 1020 insertions(+), 45 deletions(-) diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index dda9a4da..d3329c4a 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -159,6 +159,12 @@ android { isMinifyEnabled = false isDebuggable = true signingConfig = signingConfigs.getByName("debug") + + // Allow locally signed test builds to coexist with an installed release build. + // Example: -PDROIDSPACES_APP_ID_SUFFIX=.dev + providers.gradleProperty("DROIDSPACES_APP_ID_SUFFIX").orNull + ?.takeIf { it.isNotBlank() } + ?.let { applicationIdSuffix = it } } } diff --git a/Android/app/src/main/assets/post_extract_fixes.sh b/Android/app/src/main/assets/post_extract_fixes.sh index f6f72b45..6b40f306 100755 --- a/Android/app/src/main/assets/post_extract_fixes.sh +++ b/Android/app/src/main/assets/post_extract_fixes.sh @@ -184,18 +184,17 @@ EOF $PRINTF "[Unit]\nConditionPathIsReadWrite=\n" > "$ROOTFS_PATH/etc/systemd/system/${unit}.d/99-readonly-fix.conf" done - # 06. Limit specific network services to NAT and gateway modes only - # Both need an in-container DHCP client (NAT: lease from Droidspaces; gateway: - # lease from the gateway container, e.g. OpenWRT). Host/none modes still skip - # them to prevent cellular network breakage. - log "Applying NAT/gateway mode guards to network services..." + # 06. Limit network services to modes where the guest owns DHCP. Direct-L2 + # static mode is configured by the runtime, so DHCP managers must not race + # it. Host/none modes still skip them to protect Android host interfaces. + log "Applying network-mode guards to guest network services..." for unit in NetworkManager.service dhcpcd.service systemd-resolved.service systemd-networkd.service; do if $TEST -f "$ROOTFS_PATH/$GUEST_SYSTEMD_PATH/$unit" || $TEST -f "$ROOTFS_PATH/etc/systemd/system/multi-user.target.wants/$unit"; then $MKDIR -p "$ROOTFS_PATH/etc/systemd/system/${unit}.d" $CAT > "$ROOTFS_PATH/etc/systemd/system/${unit}.d/99-netmode-limit.conf" << 'EOF' [Service] ExecCondition= -ExecCondition=/bin/sh -c "grep -qE 'net_mode=(nat|gateway)' /run/droidspaces/container.config" +ExecCondition=/bin/sh -c "grep -qE 'net_mode=(nat|gateway)' /run/droidspaces/container.config || { grep -qE 'net_mode=(ipvlan|macvlan)' /run/droidspaces/container.config && ! grep -q 'net_ipam=static' /run/droidspaces/container.config; }" EOF fi done @@ -212,6 +211,7 @@ DHCP=yes IPv6AcceptRA=yes [DHCPv4] +ClientIdentifier=duid UseDNS=yes UseDomains=yes RouteMetric=100 @@ -223,13 +223,13 @@ fi # --- 3. Alpine/OpenRC & dhcpcd-Specific Fixes --- -# Replace dhcpcd init script to only start in NAT or gateway network mode +# Replace dhcpcd init script to start only when the guest owns DHCP # This is the OpenRC equivalent of systemd's ExecCondition - if the container # is running in host network mode, dhcpcd is cleanly skipped at boot to prevent # cellular network breakage and kernel panics on Android interfaces. Gateway # mode needs it too: the DHCP lease comes from the gateway container. if $TEST -f "$ROOTFS_PATH/etc/init.d/dhcpcd"; then - log "Alpine/OpenRC dhcpcd service detected, applying NAT/gateway mode limitation..." + log "Alpine/OpenRC dhcpcd service detected, applying network-mode limitation..." $CAT > "$ROOTFS_PATH/etc/init.d/dhcpcd" << 'INITEOF' #!/sbin/openrc-run @@ -249,9 +249,11 @@ depend() { } start_pre() { - # Only start in NAT or gateway mode - prevents cellular network breakage in host network mode - if ! grep -qE 'net_mode=(nat|gateway)' /run/droidspaces/container.config 2>/dev/null; then - einfo "Skipping dhcpcd: not in NAT or gateway network mode" + # Direct-L2 DHCP is guest-owned; direct-L2 static is runtime-owned. + if ! grep -qE 'net_mode=(nat|gateway)' /run/droidspaces/container.config 2>/dev/null && + ! { grep -qE 'net_mode=(ipvlan|macvlan)' /run/droidspaces/container.config 2>/dev/null && + ! grep -q 'net_ipam=static' /run/droidspaces/container.config 2>/dev/null; }; then + einfo "Skipping dhcpcd: network mode is not guest-managed DHCP" return 1 fi checkpath -d /run/dhcpcd diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt index ba426c3c..2b55f32c 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt @@ -158,7 +158,7 @@ fun ContainerCard( // Info Rows val displayHostname = container.hostname.takeIf { it.isNotEmpty() } ?: container.name val hasSparseImage = container.useSparseImage && container.sparseImageSizeGB != null - val netModeLabel = when (container.netMode) { "nat" -> context.getString(R.string.network_mode_nat_short); "none" -> context.getString(R.string.network_mode_none_short); "gateway" -> context.getString(R.string.network_mode_gateway_short); else -> context.getString(R.string.network_mode_host_short) } + val netModeLabel = when (container.netMode) { "nat" -> context.getString(R.string.network_mode_nat_short); "none" -> context.getString(R.string.network_mode_none_short); "gateway" -> context.getString(R.string.network_mode_gateway_short); "ipvlan" -> context.getString(R.string.network_mode_ipvlan_short); "macvlan" -> context.getString(R.string.network_mode_macvlan_short); else -> context.getString(R.string.network_mode_host_short) } Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { Icon(Icons.Default.Computer, null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)) Text(context.getString(R.string.hostname_label, displayHostname), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt index 07f0c6ad..32dda592 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt @@ -44,11 +44,15 @@ import androidx.compose.material.icons.filled.NetworkCheck import androidx.compose.material.icons.filled.PowerSettingsNew import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.SettingsEthernet import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.Terminal import androidx.compose.material.icons.filled.Warning import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -81,8 +85,11 @@ import com.droidspaces.app.util.BindMount import com.droidspaces.app.util.Constants import com.droidspaces.app.util.ContainerConfigState import com.droidspaces.app.util.ContainerInfo +import com.droidspaces.app.util.ContainerManager import com.droidspaces.app.util.GatewayErrors import com.droidspaces.app.util.ValidationUtils +import com.droidspaces.app.util.isDirectNetworkValid +import com.droidspaces.app.util.isNetMacValid /** * The single, shared container-configuration form used by both the Create wizard @@ -117,6 +124,16 @@ fun ContainerConfigForm( var showEnvDialog by remember { mutableStateOf(false) } var showPrivilegedDialog by remember { mutableStateOf(false) } var showHwAccessDialog by remember { mutableStateOf(false) } + var parentInterfaceMenuExpanded by remember { mutableStateOf(false) } + var availableParentInterfaces by remember { mutableStateOf>(emptyList()) } + + LaunchedEffect(state.netMode) { + if (state.netMode == "ipvlan" || state.netMode == "macvlan") { + availableParentInterfaces = ContainerManager.listUpstreamInterfaces() + } else { + parentInterfaceMenuExpanded = false + } + } val modernFieldShape = RoundedCornerShape(16.dp) val modernFieldColors = DsTextFieldDefaults.colors() @@ -261,11 +278,11 @@ fun ContainerConfigForm( DsDropdown( label = context.getString(R.string.network_mode), selected = state.netMode, - options = listOf("nat", "host", "none", "gateway"), - displayName = { context.getString(when (it) { "nat" -> R.string.network_mode_nat; "none" -> R.string.network_mode_none; "gateway" -> R.string.network_mode_gateway; else -> R.string.network_mode_host }) }, + options = listOf("nat", "host", "none", "gateway", "ipvlan", "macvlan"), + displayName = { context.getString(when (it) { "nat" -> R.string.network_mode_nat; "none" -> R.string.network_mode_none; "gateway" -> R.string.network_mode_gateway; "ipvlan" -> R.string.network_mode_ipvlan; "macvlan" -> R.string.network_mode_macvlan; else -> R.string.network_mode_host }) }, onSelect = { mode -> clearFocus() - onStateChange(state.copy(netMode = mode, disableIPv6 = if (mode != "host") false else state.disableIPv6)) + onStateChange(state.copy(netMode = mode, disableIPv6 = if (mode == "nat" || mode == "none") true else state.disableIPv6)) }, leadingIcon = Icons.Default.Public ) @@ -284,6 +301,141 @@ fun ContainerConfigForm( errors = gatewayErrors ) + if (state.netMode == "ipvlan" || state.netMode == "macvlan") { + val parentError = state.netParent.isNotEmpty() && + (state.netParent.length >= 16 || state.netParent.any { it.isWhitespace() || it == '/' }) + val staticError = state.netIpam == "static" && !state.isDirectNetworkValid() + Column( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = context.getString(R.string.direct_l2_settings), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = context.getString(R.string.direct_l2_explain), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + ExposedDropdownMenuBox( + expanded = parentInterfaceMenuExpanded, + onExpandedChange = { parentInterfaceMenuExpanded = it }, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = state.netParent, + onValueChange = { + onStateChange(state.copy(netParent = it.trim())) + parentInterfaceMenuExpanded = true + }, + label = { Text(context.getString(R.string.net_parent)) }, + placeholder = { Text(context.getString(R.string.net_parent_auto)) }, + supportingText = { + Text(context.getString(if (parentError) R.string.net_parent_error else R.string.net_parent_explain)) + }, + isError = parentError, + modifier = Modifier.menuAnchor().fillMaxWidth(), + singleLine = true, + shape = modernFieldShape, + colors = modernFieldColors, + leadingIcon = { Icon(Icons.Default.Public, contentDescription = null) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon( + expanded = parentInterfaceMenuExpanded + ) + } + ) + ExposedDropdownMenu( + expanded = parentInterfaceMenuExpanded, + onDismissRequest = { parentInterfaceMenuExpanded = false } + ) { + DropdownMenuItem( + text = { Text(context.getString(R.string.net_parent_auto)) }, + onClick = { + onStateChange(state.copy(netParent = "")) + parentInterfaceMenuExpanded = false + clearFocus() + } + ) + availableParentInterfaces + .filter { it.length < 16 && it.none { ch -> ch.isWhitespace() || ch == '/' } } + .distinct() + .forEach { iface -> + DropdownMenuItem( + text = { Text(iface) }, + onClick = { + onStateChange(state.copy(netParent = iface)) + parentInterfaceMenuExpanded = false + clearFocus() + } + ) + } + } + } + if (state.netMode == "macvlan") { + val macError = !state.isNetMacValid() + OutlinedTextField( + value = state.netMac, + onValueChange = { onStateChange(state.copy(netMac = it.trim())) }, + label = { Text(context.getString(R.string.net_mac)) }, + placeholder = { Text("02:11:22:33:44:55") }, + supportingText = { + Text(context.getString(if (macError) R.string.net_mac_error else R.string.net_mac_explain)) + }, + isError = macError, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = modernFieldShape, + colors = modernFieldColors, + leadingIcon = { Icon(Icons.Default.SettingsEthernet, contentDescription = null) } + ) + } + DsDropdown( + label = context.getString(R.string.net_ipam), + selected = state.netIpam, + options = listOf("dhcp", "static"), + displayName = { context.getString(if (it == "static") R.string.net_ipam_static else R.string.net_ipam_dhcp) }, + onSelect = { onStateChange(state.copy(netIpam = it)) }, + leadingIcon = Icons.Default.NetworkCheck + ) + if (state.netIpam == "static") { + OutlinedTextField( + value = state.netAddress, + onValueChange = { onStateChange(state.copy(netAddress = it.trim())) }, + label = { Text(context.getString(R.string.net_address)) }, + placeholder = { Text("192.168.1.50/24") }, + isError = state.netAddress.isNotEmpty() && staticError, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + shape = modernFieldShape, + colors = modernFieldColors + ) + OutlinedTextField( + value = state.netGateway, + onValueChange = { onStateChange(state.copy(netGateway = it.trim())) }, + label = { Text(context.getString(R.string.net_gateway)) }, + placeholder = { Text("192.168.1.1") }, + supportingText = { if (staticError) Text(context.getString(R.string.net_static_error)) }, + isError = state.netGateway.isNotEmpty() && staticError, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + shape = modernFieldShape, + colors = modernFieldColors + ) + } else if (state.netMode == "ipvlan") { + Text( + text = context.getString(R.string.ipvlan_dhcp_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary + ) + } + } + } + if (state.netMode == "nat") { Column( modifier = Modifier.fillMaxWidth().padding(top = 8.dp), @@ -441,7 +593,7 @@ fun ContainerConfigForm( leadingIcon = { Icon(Icons.Default.Dns, contentDescription = null) } ) - val ipv6IsForced = state.netMode != "host" + val ipv6IsForced = state.netMode == "nat" || state.netMode == "none" ToggleCard( icon = Icons.Default.NetworkCheck, title = context.getString(R.string.disable_ipv6), diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt index bd8c8888..c36ce8c3 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt @@ -44,6 +44,7 @@ import com.droidspaces.app.ui.util.ClearFocusOnClickOutside import com.droidspaces.app.util.ContainerConfigState import com.droidspaces.app.util.ContainerInfo import com.droidspaces.app.util.ValidationUtils +import com.droidspaces.app.util.isDirectNetworkValid @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -72,7 +73,8 @@ fun ContainerConfigScreen( else installedContainers.find { it.name != containerName && it.staticNatIp == state.staticNatIp } } - val canProceed = (state.netMode != "gateway" || gatewayErrors.isValid) && collisionContainer == null + val canProceed = (state.netMode != "gateway" || gatewayErrors.isValid) && + state.isDirectNetworkValid() && collisionContainer == null Scaffold( topBar = { diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt index 13774042..45a4eb4c 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt @@ -64,6 +64,7 @@ import com.droidspaces.app.util.SystemInfoManager import com.droidspaces.app.util.ValidationUtils import com.droidspaces.app.util.toConfigState import com.droidspaces.app.util.withConfig +import com.droidspaces.app.util.isDirectNetworkValid import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -170,7 +171,8 @@ fun EditContainerScreen( bottomBar = { val btnShape = RoundedCornerShape(20.dp) val isReadyToSave = !isSaving && !isSaved && hasChanges && hostnameError == null && - (state.netMode != "gateway" || gatewayErrors.isValid) && collisionContainer == null + (state.netMode != "gateway" || gatewayErrors.isValid) && + state.isDirectNetworkValid() && collisionContainer == null val targetBtnColor = when { isSaved -> MaterialTheme.colorScheme.primaryContainer isSaving || isReadyToSave -> MaterialTheme.colorScheme.primary diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt index 317d7b01..50e60028 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt @@ -93,12 +93,20 @@ fun InstallationSummaryScreen( SummaryItem(stringResource(R.string.hostname), config.hostname, Icons.Default.Computer) SummaryItem( stringResource(R.string.network_mode), - stringResource(when (config.netMode) { "nat" -> R.string.network_mode_nat; "none" -> R.string.network_mode_none; else -> R.string.network_mode_host }), + stringResource(when (config.netMode) { "nat" -> R.string.network_mode_nat; "none" -> R.string.network_mode_none; "gateway" -> R.string.network_mode_gateway; "ipvlan" -> R.string.network_mode_ipvlan; "macvlan" -> R.string.network_mode_macvlan; else -> R.string.network_mode_host }), Icons.Default.Public ) if (config.netMode == "nat" && config.staticNatIp.isNotEmpty()) { SummaryItem(stringResource(R.string.static_ip_address), config.staticNatIp, Icons.Default.NetworkCheck) } + if (config.netMode == "ipvlan" || config.netMode == "macvlan") { + SummaryItem(stringResource(R.string.net_parent), config.netParent, Icons.Default.Public) + SummaryItem( + stringResource(R.string.net_ipam), + stringResource(if (config.netIpam == "static") R.string.net_ipam_static else R.string.net_ipam_dhcp), + Icons.Default.NetworkCheck + ) + } if (config.useSparseImage && config.sparseImageSizeGB != null) { SummaryItem(stringResource(R.string.storage_configuration), "${stringResource(R.string.sparse_image_configuration)} (${config.sparseImageSizeGB}GB)", Icons.Default.Storage) } else { diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/AppStateViewModel.kt b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/AppStateViewModel.kt index ec0fd440..f8870ea9 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/AppStateViewModel.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/AppStateViewModel.kt @@ -217,7 +217,15 @@ class AppStateViewModel(application: Application) : AndroidViewModel(application binaryResult.fold( onSuccess = { if (wasDaemon) { - BinaryInstaller.signalDaemon() + val daemonRestart = BinaryInstaller.restartDaemon() + if (daemonRestart.isFailure) { + installErrorMessage = daemonRestart.exceptionOrNull()?.message + ?: context.getString(R.string.binary_installation_failed) + isInstalling = false + resetForPostInstallation() + forceRefresh() + return@fold + } } // Step 3: Install module isInstallingModule = true diff --git a/Android/app/src/main/java/com/droidspaces/app/util/BinaryInstaller.kt b/Android/app/src/main/java/com/droidspaces/app/util/BinaryInstaller.kt index 26e39a29..7d628049 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/BinaryInstaller.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/BinaryInstaller.kt @@ -63,9 +63,9 @@ object BinaryInstaller { val droidspacesBinaryName = getDroidspacesBinaryName() val busyboxBinaryName = getBusyboxBinaryName() - // Always install to the canonical path. The daemon's g_self_path fix - // means this is safe even while the daemon is running - the mv is - // atomic and the daemon automatically re-execs the new binary. + // Always install to the canonical path. The move is atomic, so an + // already-running daemon can keep using its old inode until the app + // restarts the daemon after the swap. val droidspacesTargetPath = Constants.DROIDSPACES_BINARY_PATH val busyboxTargetPath = Constants.BUSYBOX_BINARY_PATH @@ -176,16 +176,61 @@ object BinaryInstaller { } /** - * After a live binary swap, send SIGUSR2 to the running daemon - * so it acknowledges the update (used for logging). + * Restart the complete daemon tree after an atomic backend update. + * + * Re-executed CLI sessions already resolve the new canonical binary, but + * the daemon's embedded socketd bridge stays mapped to the old inode. If a + * release changes an internal request/config layout, mixing that old bridge + * with the new CLI can reinterpret unrelated flags. Restarting the parent + * also terminates its bridge through PR_SET_PDEATHSIG, so both processes + * come back from the same binary. */ - suspend fun signalDaemon(): Unit = withContext(Dispatchers.IO) { - val pidResult = Shell.cmd("cat ${Constants.DAEMON_PID_FILE} 2>/dev/null").exec() - if (pidResult.isSuccess && pidResult.out.isNotEmpty()) { - val pid = pidResult.out[0].trim() - if (pid.isNotEmpty()) { - Shell.cmd("kill -USR2 $pid 2>/dev/null").exec() + suspend fun restartDaemon(): Result = withContext(Dispatchers.IO) { + try { + val oldPid = Shell.cmd("cat ${Constants.DAEMON_PID_FILE} 2>/dev/null") + .exec().out.firstOrNull()?.trim()?.toIntOrNull() + + if (oldPid != null && oldPid > 1) { + Shell.cmd("kill -TERM $oldPid 2>/dev/null").exec() + + var alive = true + for (attempt in 0 until 30) { + alive = Shell.cmd("kill -0 $oldPid 2>/dev/null").exec().isSuccess + if (!alive) break + Thread.sleep(100) + } + if (alive) { + Shell.cmd("kill -KILL $oldPid 2>/dev/null").exec() + Thread.sleep(100) + } + } + + // Preserve the daemon SELinux entrypoint label used by the module. + Shell.cmd( + "chcon u:object_r:droidspacesd_exec:s0 ${Constants.DROIDSPACES_BINARY_PATH} 2>/dev/null" + ).exec() + + val launch = Shell.cmd("${Constants.DROIDSPACES_BINARY_PATH} daemon 2>&1").exec() + if (!launch.isSuccess) { + return@withContext Result.failure( + Exception("Failed to restart Droidspaces daemon: ${launch.err.joinToString()}") + ) } + + repeat(30) { + val pid = Shell.cmd("cat ${Constants.DAEMON_PID_FILE} 2>/dev/null") + .exec().out.firstOrNull()?.trim()?.toIntOrNull() + if (pid != null && pid > 1 && + Shell.cmd("kill -0 $pid 2>/dev/null").exec().isSuccess + ) { + return@withContext Result.success(Unit) + } + Thread.sleep(100) + } + + Result.failure(Exception("Droidspaces daemon did not become ready after update")) + } catch (error: Exception) { + Result.failure(error) } } diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerConfigState.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerConfigState.kt index 2de58003..39d71163 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ContainerConfigState.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerConfigState.kt @@ -16,6 +16,11 @@ package com.droidspaces.app.util */ data class ContainerConfigState( val netMode: String = "nat", + val netParent: String = "", + val netMac: String = "", + val netIpam: String = "dhcp", + val netAddress: String = "", + val netGateway: String = "", val disableIPv6: Boolean = false, val enableAndroidStorage: Boolean = false, val enableHwAccess: Boolean = false, @@ -48,6 +53,11 @@ data class ContainerConfigState( /** Extract the editable config fields from an existing container. */ fun ContainerInfo.toConfigState(): ContainerConfigState = ContainerConfigState( netMode = netMode, + netParent = netParent, + netMac = netMac, + netIpam = netIpam, + netAddress = netAddress, + netGateway = netGateway, disableIPv6 = disableIPv6, enableAndroidStorage = enableAndroidStorage, enableHwAccess = enableHwAccess, @@ -84,6 +94,11 @@ fun ContainerInfo.toConfigState(): ContainerConfigState = ContainerConfigState( */ fun ContainerInfo.withConfig(state: ContainerConfigState): ContainerInfo = copy( netMode = state.netMode, + netParent = state.netParent, + netMac = state.netMac, + netIpam = state.netIpam, + netAddress = state.netAddress, + netGateway = state.netGateway, disableIPv6 = state.disableIPv6, enableAndroidStorage = state.enableAndroidStorage, enableHwAccess = state.enableHwAccess, @@ -112,3 +127,37 @@ fun ContainerInfo.withConfig(state: ContainerConfigState): ContainerInfo = copy( gatewayIface = state.gatewayIface, gatewayBridge = state.gatewayBridge, ) + +private fun String.isValidIpv4(): Boolean { + val octets = split('.') + return octets.size == 4 && octets.all { + it.isNotEmpty() && it.length <= 3 && it.all(Char::isDigit) && + (it.toIntOrNull() ?: -1) in 0..255 + } +} + +private fun String.isValidUnicastMac(): Boolean { + val octets = split(':') + if (octets.size != 6 || octets.any { it.length != 2 || it.toIntOrNull(16) == null }) return false + val bytes = octets.map { it.toInt(16) } + return bytes.any { it != 0 } && (bytes[0] and 1) == 0 +} + +fun ContainerConfigState.isNetMacValid(): Boolean = + netMode != "macvlan" || netMac.isBlank() || netMac.isValidUnicastMac() + +/** Validation shared by create/edit action gating and the direct-L2 form. */ +fun ContainerConfigState.isDirectNetworkValid(): Boolean { + if (netMode != "ipvlan" && netMode != "macvlan") return true + // Blank selects the backend's Android-aware active-uplink detection. + if (netParent.isNotBlank() && (netParent.length >= 16 || + netParent.any { it.isWhitespace() || it == '/' })) return false + if (!isNetMacValid()) return false + if (netIpam == "dhcp") return true + if (netIpam != "static") return false + val parts = netAddress.split('/', limit = 2) + val prefix = parts.getOrNull(1)?.toIntOrNull() + return parts.size == 2 && parts[0].isValidIpv4() && prefix != null && + prefix in 0..32 && + netGateway.isValidIpv4() +} diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt index 029ee473..a7f4c040 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt @@ -30,6 +30,11 @@ data class ContainerInfo( val hostname: String, val rootfsPath: String, val netMode: String = Constants.DEFAULT_NET_MODE, + val netParent: String = "", + val netMac: String = "", + val netIpam: String = "dhcp", + val netAddress: String = "", + val netGateway: String = "", val disableIPv6: Boolean = false, val enableAndroidStorage: Boolean = false, val enableHwAccess: Boolean = false, @@ -75,6 +80,15 @@ data class ContainerInfo( appendLine("hostname=$hostname") appendLine("rootfs_path=$rootfsPath") appendLine("net_mode=$netMode") + if (netMode == "ipvlan" || netMode == "macvlan") { + appendLine("net_parent=$netParent") + if (netMode == "macvlan" && netMac.isNotBlank()) appendLine("net_mac=$netMac") + appendLine("net_ipam=$netIpam") + if (netIpam == "static") { + appendLine("net_address=$netAddress") + appendLine("net_gateway=$netGateway") + } + } appendLine("disable_ipv6=${if (disableIPv6) "1" else "0"}") appendLine("enable_android_storage=${if (enableAndroidStorage) "1" else "0"}") appendLine("enable_hw_access=${if (enableHwAccess) "1" else "0"}") @@ -319,6 +333,11 @@ object ContainerManager { getRootfsPath(containerName) }, netMode = configMap["net_mode"] ?: Constants.DEFAULT_NET_MODE, + netParent = configMap["net_parent"] ?: "", + netMac = configMap["net_mac"] ?: "", + netIpam = configMap["net_ipam"] ?: "dhcp", + netAddress = configMap["net_address"] ?: "", + netGateway = configMap["net_gateway"] ?: "", disableIPv6 = configMap["disable_ipv6"] == "1", enableAndroidStorage = configMap["enable_android_storage"] == "1", enableHwAccess = configMap["enable_hw_access"] == "1", diff --git a/Android/app/src/main/res/values-zh-rCN/strings.xml b/Android/app/src/main/res/values-zh-rCN/strings.xml index 1e9c99f9..ea1dc2ff 100644 --- a/Android/app/src/main/res/values-zh-rCN/strings.xml +++ b/Android/app/src/main/res/values-zh-rCN/strings.xml @@ -475,6 +475,27 @@ 拖动以重新排序 网关(虚拟路由器) 网关 + IPvlan(直连局域网) + IPvlan + Macvlan(直连局域网) + Macvlan + 直连局域网设置 + 在 Android 父接口上创建子链路。DHCP 由容器内的 NetworkManager、networkd 或 dhcpcd 发起,由外部路由器应答;Droidspaces 不启用 NAT,也不运行内置 DHCP 服务。 + 父接口 + 自动检测(活动上行),或例如 wlan0 + 自动检测(活动上行) + 留空时自动检测 Android 当前活动上行,也可明确指定父接口。 + 请输入少于 16 个字符的 Linux 接口名。 + 设备 MAC(可选) + 留空则由内核选择;填写单播 MAC 可配合路由器固定租约。 + 请输入有效的单播 MAC,例如 02:11:22:33:44:55。 + 地址配置 + DHCP(容器网络管理器) + 静态 + IPv4 地址 / 前缀 + 默认网关 + 请输入有效的 IPv4 CIDR 和 IPv4 网关。 + IPvlan 与父接口共用 MAC。Droidspaces 会让 systemd-networkd 使用 DUID 客户端标识,但部分路由器只按 MAC 识别租约,可能无法分配独立地址。 网关设置 把这个容器通过另一个充当路由器的容器(例如 OpenWRT)路由。该网关必须先运行起来。 网关容器 diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml index bcaf3a50..a5cec738 100644 --- a/Android/app/src/main/res/values/strings.xml +++ b/Android/app/src/main/res/values/strings.xml @@ -236,6 +236,27 @@ None Gateway (Virtual Router) Gateway + IPvlan (Direct LAN) + IPvlan + Macvlan (Direct LAN) + Macvlan + Direct LAN settings + Creates a child link on an Android host interface. DHCP is handled inside the container by NetworkManager, networkd, or dhcpcd and answered by the external router; Droidspaces does not run NAT or its embedded DHCP server. + Parent interface + Automatic (active uplink), or e.g. wlan0 + Automatic (active uplink) + Leave blank to auto-detect the active Android uplink, or enter a parent interface explicitly. + Enter a Linux interface name shorter than 16 characters. + Device MAC (optional) + Leave blank to let the kernel choose; enter a unicast MAC to keep a router reservation. + Enter a valid unicast MAC such as 02:11:22:33:44:55. + Address configuration + DHCP (guest network manager) + Static + IPv4 address / prefix + Default gateway + Enter a valid IPv4 CIDR and IPv4 gateway. + IPvlan shares the parent MAC. Droidspaces asks systemd-networkd to use a DUID client ID, but some routers identify leases only by MAC and may not issue separate leases. Gateway Settings diff --git a/src/boot.c b/src/boot.c index fca53531..81f4ccf1 100644 --- a/src/boot.c +++ b/src/boot.c @@ -9,6 +9,67 @@ #include #include +/* Per-boot network-service policy. /run overrides stale rootfs drop-ins, so + * old images immediately learn about ipvlan/macvlan without being re-extracted. + * Host/none and direct-L2 static must keep guest managers stopped; NAT, + * gateway, and direct-L2 DHCP let the guest own eth0 configuration. */ +static void ds_write_guest_network_policy(const struct ds_config *cfg) { + if (!cfg) + return; + + int direct_dhcp = + (cfg->net_mode == DS_NET_IPVLAN || cfg->net_mode == DS_NET_MACVLAN) && + cfg->net_ipam == DS_NET_IPAM_DHCP; + int allow_guest = cfg->net_mode == DS_NET_NAT || + cfg->net_mode == DS_NET_GATEWAY || direct_dhcp; + + mkdir("run/systemd", 0755); + mkdir("run/systemd/system", 0755); + const char *units[] = {"NetworkManager.service", "dhcpcd.service", + "systemd-networkd.service", + "systemd-resolved.service", NULL}; + const char *allow = "[Service]\nExecCondition=\n"; + const char *block = "[Service]\nExecCondition=\nExecCondition=/bin/false\n"; + for (size_t i = 0; units[i]; i++) { + char dir[PATH_MAX]; + char file[PATH_MAX]; + snprintf(dir, sizeof(dir), "run/systemd/system/%s.d", units[i]); + if (mkdir(dir, 0755) < 0 && errno != EEXIST) { + ds_warn("[NET] Cannot create systemd network policy directory %s: %s", + dir, strerror(errno)); + continue; + } + snprintf(file, sizeof(file), "%s/zz-droidspaces-netmode.conf", dir); + if (write_file(file, allow_guest ? allow : block) < 0) + ds_warn("[NET] Cannot write systemd network policy %s", file); + } + + if (direct_dhcp) { + mkdir("run/systemd/network", 0755); + const char *network = + "[Match]\n" + "Name=eth0\n\n" + "[Network]\n" + "DHCP=ipv4\n" + "IPv6AcceptRA=yes\n" + "LinkLocalAddressing=ipv6\n\n" + "[DHCPv4]\n" + "ClientIdentifier=mac\n" + "UseDNS=yes\n" + "UseDomains=yes\n" + "RouteMetric=100\n\n" + "[IPv6AcceptRA]\n" + "UseDNS=yes\n"; + if (write_file("run/systemd/network/10-droidspaces-eth0.network", + network) < 0) + ds_warn("[NET] Cannot write direct-L2 systemd-networkd configuration"); + } + + ds_log("[NET] Guest network services: %s (mode=%d, ipam=%s)", + allow_guest ? "enabled" : "blocked", cfg->net_mode, + cfg->net_ipam == DS_NET_IPAM_STATIC ? "static" : "dhcp"); +} + /* * ds_apply_capability_hardening() * @@ -144,9 +205,18 @@ int internal_boot(struct ds_config *cfg) { } } + if ((cfg->net_mode == DS_NET_IPVLAN || + cfg->net_mode == DS_NET_MACVLAN) && + hs.status < 0) + ds_die("Direct L2 network setup failed: %s", strerror(-hs.status)); + /* Configure our side of the veth (or just loopback for DS_NET_NONE) */ if (cfg->net_mode == DS_NET_NAT || cfg->net_mode == DS_NET_GATEWAY) { setup_veth_child_side_named(cfg, hs.peer_name, hs.ip_str); + } else if (cfg->net_mode == DS_NET_IPVLAN || + cfg->net_mode == DS_NET_MACVLAN) { + if (setup_parent_link_child_side(cfg, hs.peer_name) < 0) + ds_die("Failed to configure direct L2 interface inside container"); } else { /* DS_NET_NONE: just bring up loopback */ ds_nl_ctx_t *nlctx = ds_nl_open(); @@ -481,6 +551,8 @@ int internal_boot(struct ds_config *cfg) { ds_warn("Boot: Failed to save internal configuration backup"); } + ds_write_guest_network_policy(cfg); + write_file("run/droidspaces/name", cfg->container_name); if (cfg->img_mount_point[0]) diff --git a/src/config.c b/src/config.c index 1a5e507a..763d2eb1 100644 --- a/src/config.c +++ b/src/config.c @@ -369,12 +369,37 @@ int ds_config_load(const char *config_path, struct ds_config *cfg) { } else if (strcmp(val, "gateway") == 0 || strcmp(val, "delegated-gateway") == 0) { cfg->net_mode = DS_NET_GATEWAY; + } else if (strcmp(val, "ipvlan") == 0) { + cfg->net_mode = DS_NET_IPVLAN; + } else if (strcmp(val, "macvlan") == 0) { + cfg->net_mode = DS_NET_MACVLAN; } else { ds_warn( "Unknown network mode '%s' in config file. Defaulting to 'host'.", val); cfg->net_mode = DS_NET_HOST; } + } else if (strcmp(key, "net_ipam") == 0) { + if (strcmp(val, "static") == 0) + cfg->net_ipam = DS_NET_IPAM_STATIC; + else if (strcmp(val, "dhcp") == 0) + cfg->net_ipam = DS_NET_IPAM_DHCP; + else + ds_warn("config: unknown net_ipam '%s'; using dhcp", val); + } else if (strcmp(key, "net_parent") == 0) { + if (strlen(val) < IFNAMSIZ) + safe_strncpy(cfg->net_parent, val, sizeof(cfg->net_parent)); + else + ds_warn("config: ignoring too-long net_parent '%s'", val); + } else if (strcmp(key, "net_mac") == 0) { + if (strlen(val) < sizeof(cfg->net_mac)) + safe_strncpy(cfg->net_mac, val, sizeof(cfg->net_mac)); + else + ds_warn("config: ignoring too-long net_mac '%s'", val); + } else if (strcmp(key, "net_address") == 0) { + safe_strncpy(cfg->net_address, val, sizeof(cfg->net_address)); + } else if (strcmp(key, "net_gateway") == 0) { + safe_strncpy(cfg->net_gateway, val, sizeof(cfg->net_gateway)); } else if (strcmp(key, "gateway_container") == 0) { if (validate_container_name(val)) safe_strncpy(cfg->gateway_container, val, @@ -707,10 +732,29 @@ static void ds_config_serialize_known(FILE *f, struct ds_config *cfg) { fprintf(f, "net_mode=none\n"); } else if (cfg->net_mode == DS_NET_GATEWAY) { fprintf(f, "net_mode=gateway\n"); + } else if (cfg->net_mode == DS_NET_IPVLAN) { + fprintf(f, "net_mode=ipvlan\n"); + } else if (cfg->net_mode == DS_NET_MACVLAN) { + fprintf(f, "net_mode=macvlan\n"); } else { fprintf(f, "net_mode=host\n"); } + if (cfg->net_mode == DS_NET_IPVLAN || cfg->net_mode == DS_NET_MACVLAN) { + if (cfg->net_parent[0]) + fprintf(f, "net_parent=%s\n", cfg->net_parent); + fprintf(f, "net_ipam=%s\n", + cfg->net_ipam == DS_NET_IPAM_STATIC ? "static" : "dhcp"); + if (cfg->net_mode == DS_NET_MACVLAN && cfg->net_mac[0]) + fprintf(f, "net_mac=%s\n", cfg->net_mac); + if (cfg->net_ipam == DS_NET_IPAM_STATIC) { + if (cfg->net_address[0]) + fprintf(f, "net_address=%s\n", cfg->net_address); + if (cfg->net_gateway[0]) + fprintf(f, "net_gateway=%s\n", cfg->net_gateway); + } + } + if (cfg->net_mode == DS_NET_GATEWAY) { if (cfg->gateway_container[0]) fprintf(f, "gateway_container=%s\n", cfg->gateway_container); @@ -916,6 +960,33 @@ int ds_config_validate(struct ds_config *cfg) { errors++; } + if (cfg->net_mode == DS_NET_IPVLAN || cfg->net_mode == DS_NET_MACVLAN) { + /* Empty is valid: the active host uplink is resolved at start time. */ + if (cfg->net_parent[0] && + (strlen(cfg->net_parent) >= IFNAMSIZ || + strchr(cfg->net_parent, '/') || + strpbrk(cfg->net_parent, " \t\r\n"))) + errors++; + if (cfg->net_ipam == DS_NET_IPAM_STATIC) { + char address[sizeof(cfg->net_address)]; + safe_strncpy(address, cfg->net_address, sizeof(address)); + char *slash = strchr(address, '/'); + char *end = NULL; + long prefix = slash ? strtol(slash + 1, &end, 10) : -1; + if (slash) + *slash = '\0'; + struct in_addr parsed; + if (!slash || !end || *end || prefix < 0 || prefix > 32 || + inet_pton(AF_INET, address, &parsed) != 1 || + inet_pton(AF_INET, cfg->net_gateway, &parsed) != 1) + errors++; + } + if ((cfg->net_mode == DS_NET_MACVLAN && cfg->net_mac[0] && + ds_parse_mac_address(cfg->net_mac, (uint8_t[6]){0}) < 0) || + (cfg->net_mode == DS_NET_IPVLAN && cfg->net_mac[0])) + errors++; + } + return (errors > 0) ? -1 : 0; } diff --git a/src/container.c b/src/container.c index 6822836a..88731229 100644 --- a/src/container.c +++ b/src/container.c @@ -1593,6 +1593,12 @@ int show_info(struct ds_config *cfg, int trust_cfg_pid) { case DS_NET_GATEWAY: net = "gateway"; break; + case DS_NET_IPVLAN: + net = "ipvlan"; + break; + case DS_NET_MACVLAN: + net = "macvlan"; + break; default: net = "host"; break; @@ -1620,6 +1626,15 @@ int show_info(struct ds_config *cfg, int trust_cfg_pid) { printf("GATEWAY_BRIDGE=%s\n", cfg->gateway_bridge); printf("GATEWAY_IFACE=%s\n", cfg->gateway_lan_ifname[0] ? cfg->gateway_lan_ifname : "eth1"); + } else if (cfg->net_mode == DS_NET_IPVLAN || + cfg->net_mode == DS_NET_MACVLAN) { + printf("NET_PARENT=%s\n", cfg->net_parent); + printf("NET_IPAM=%s\n", + cfg->net_ipam == DS_NET_IPAM_STATIC ? "static" : "dhcp"); + if (cfg->net_ipam == DS_NET_IPAM_STATIC) { + printf("NET_ADDRESS=%s\n", cfg->net_address); + printf("NET_GATEWAY=%s\n", cfg->net_gateway); + } } printf("DISABLE_IPV6=%d\n", cfg->disable_ipv6); @@ -1754,6 +1769,12 @@ int show_info(struct ds_config *cfg, int trust_cfg_pid) { case DS_NET_GATEWAY: net = "gateway"; break; + case DS_NET_IPVLAN: + net = "ipvlan"; + break; + case DS_NET_MACVLAN: + net = "macvlan"; + break; default: net = "host"; break; @@ -1768,6 +1789,17 @@ int show_info(struct ds_config *cfg, int trust_cfg_pid) { feat_count++; } + if (cfg->net_mode == DS_NET_IPVLAN || cfg->net_mode == DS_NET_MACVLAN) { + printf(" Parent: %s\n", cfg->net_parent); + printf(" Addressing: %s", cfg->net_ipam == DS_NET_IPAM_STATIC + ? "static" + : "DHCP (guest manager)"); + if (cfg->net_ipam == DS_NET_IPAM_STATIC) + printf(" (%s via %s)", cfg->net_address, cfg->net_gateway); + printf("\n"); + feat_count += 2; + } + if (cfg->net_mode == DS_NET_NAT) { const char *ip = cfg->static_nat_ip[0] ? cfg->static_nat_ip : cfg->nat_container_ip; diff --git a/src/documentation.c b/src/documentation.c index 1295fc07..75993454 100644 --- a/src/documentation.c +++ b/src/documentation.c @@ -288,7 +288,16 @@ static void print_page(int page, const char *bin) { p_printf(" --net=none No network access (air-gapped)\n"); p_printf(" --net=nat Isolated namespace with internet access\n"); p_printf(" --net=gateway LAN delegated to another container " - "(e.g. OpenWRT)\n\n"); + "(e.g. OpenWRT)\n"); + p_printf(" --net=ipvlan Direct L2 child of an Android host interface\n"); + p_printf(" --net=macvlan Direct L2 child with its own MAC address\n\n"); + + p_printf("%sDirect L2 Configuration:%s\n", bold, reset); + p_printf(" --net-parent=IFACE Parent, e.g. wlan0/eth0\n"); + p_printf(" --net-ipam=dhcp Guest network manager uses " + "upstream DHCP\n"); + p_printf(" --net-ipam=static --net-address=A/P --net-gateway=G\n"); + p_printf(" No Droidspaces bridge, NAT, or embedded DHCP server is used.\n\n"); p_printf("%sNAT Mode Configuration:%s\n", bold, reset); p_printf(" %s --name=mycontainer --rootfs=/path/to/rootfs --net=nat " diff --git a/src/include/droidspace.h b/src/include/droidspace.h index a229c994..14bd3ded 100644 --- a/src/include/droidspace.h +++ b/src/include/droidspace.h @@ -205,6 +205,13 @@ enum ds_net_mode { DS_NET_NAT, /* isolated netns + bridge + MASQUERADE */ DS_NET_NONE, /* isolated netns with loopback only */ DS_NET_GATEWAY, /* isolated netns attached to gateway LAN */ + DS_NET_IPVLAN, /* ipvlan L2 child of an Android host link */ + DS_NET_MACVLAN, /* macvlan bridge child of an Android host link */ +}; + +enum ds_net_ipam { + DS_NET_IPAM_DHCP = 0, /* guest network manager requests a lease */ + DS_NET_IPAM_STATIC, /* runtime assigns net_address/gateway */ }; /* Opaque RTNETLINK context - defined in ds_netlink.c */ @@ -212,6 +219,7 @@ typedef struct ds_nl_ctx ds_nl_ctx_t; /* Handshake payload: Monitor → init via net_done_pipe */ struct ds_net_handshake { + int status; /* 0 on success, negative errno on failure */ char peer_name[16]; /* e.g. "ds-p12345" */ char ip_str[32]; /* e.g. "172.28.4.47/16" */ }; @@ -346,7 +354,12 @@ struct ds_config { char container_name[256]; /* --name= (mandatory) */ char hostname[256]; /* --hostname= or container_name */ char dns_servers[1024]; /* --dns= (comma/space separated) */ - enum ds_net_mode net_mode; /* --net=host|nat|none|gateway */ + enum ds_net_mode net_mode; /* --net=host|nat|none|gateway|... */ + enum ds_net_ipam net_ipam; /* --net-ipam=dhcp|static */ + char net_parent[IFNAMSIZ]; /* --net-parent=IFACE */ + char net_mac[18]; /* --net-mac=XX:XX:XX:XX:XX:XX */ + char net_address[32]; /* --net-address=IPv4/PREFIX */ + char net_gateway[INET_ADDRSTRLEN]; /* --net-gateway=IPv4 */ char dns_server_content[1024]; /* In-memory DNS config for boot */ char gateway_container[256]; /* --gateway=NAME for gateway mode */ char gateway_net[64]; /* --gateway-net=NAME (default: lan) */ @@ -709,6 +722,14 @@ int setup_veth_host_side(struct ds_config *cfg, pid_t child_pid); /* Gateway LAN lifecycle: bridge-only veth plumbing, no NAT/DHCP/firewall. */ int setup_gateway_veth_side(struct ds_config *cfg, pid_t child_pid); +/* Direct L2 lifecycle for ipvlan/macvlan modes. */ +int setup_parent_link_host_side(struct ds_config *cfg, pid_t child_pid); +/* Resolve a blank ipvlan/macvlan parent to the host's active uplink. An + * explicitly configured parent is preserved. */ +int ds_net_resolve_parent(struct ds_config *cfg, char *reason, size_t reason_size); +int ds_parse_mac_address(const char *text, uint8_t mac[6]); +int setup_parent_link_child_side(struct ds_config *cfg, const char *peer_name); + int setup_veth_child_side_named(struct ds_config *cfg, const char *peer_name, const char *ip_str); /* Populate a ds_net_handshake from a container init PID + resolved config. @@ -747,6 +768,8 @@ int ds_nl_link_exists(ds_nl_ctx_t *ctx, const char *ifname); int ds_nl_get_ifindex(ds_nl_ctx_t *ctx, const char *ifname); int ds_nl_create_bridge(ds_nl_ctx_t *ctx, const char *name); int ds_nl_create_veth(ds_nl_ctx_t *ctx, const char *host, const char *peer); +int ds_nl_create_parent_link(ds_nl_ctx_t *ctx, const char *parent, + const char *name, const char *kind); int ds_nl_set_master(ds_nl_ctx_t *ctx, const char *ifname, const char *master); int ds_nl_link_up(ds_nl_ctx_t *ctx, const char *ifname); int ds_nl_link_down(ds_nl_ctx_t *ctx, const char *ifname); @@ -777,6 +800,8 @@ int ds_nl_count_bridge_members_with_prefix(ds_nl_ctx_t *ctx, const char *bridge, int ds_nl_list_ifaces(ds_nl_ctx_t *ctx, char names[][IFNAMSIZ], int max); /* Kernel capability probe - call before any NAT setup */ int ds_nl_probe_nat_capability(char *reason, size_t rsz); +int ds_nl_probe_parent_capability(const char *parent, const char *kind, + char *reason, size_t rsz); /* --------------------------------------------------------------------------- * ds_iptables.c diff --git a/src/include/socketd_protocol.h b/src/include/socketd_protocol.h index 0a778a88..8ba7d3c5 100644 --- a/src/include/socketd_protocol.h +++ b/src/include/socketd_protocol.h @@ -111,7 +111,7 @@ struct DS_SOCKETD_PACKED ds_socketd_container_record { char nat_ip[INET_ADDRSTRLEN]; /* empty string if not NAT mode */ char custom_init[DS_SOCKETD_RECORD_PATH_MAX]; /* empty = /sbin/init */ int32_t pid_be; /* host-view PID 1; 0 = stopped */ - uint8_t net_mode; /* 0=host 1=nat 2=none */ + uint8_t net_mode; /* ds_net_mode wire value: host/nat/none/gateway/ipv/mac */ uint8_t port_count; /* entries used in ports[] */ uint8_t _pad[2]; struct ds_socketd_port_record ports[DS_SOCKETD_RECORD_PORTS_MAX]; @@ -171,7 +171,7 @@ struct DS_SOCKETD_PACKED ds_socketd_inspect_container_record_v1 { int64_t pids_limit_be; /* 0 = unlimited */ int32_t privileged_mask_be; - uint8_t net_mode; /* 0=host 1=nat 2=none */ + uint8_t net_mode; /* ds_net_mode wire value */ uint8_t foreground; uint8_t volatile_mode; uint8_t force_cgroupv1; diff --git a/src/main.c b/src/main.c index ed10dd98..9dee8144 100644 --- a/src/main.c +++ b/src/main.c @@ -55,7 +55,13 @@ void print_usage(void) { printf( C_BOLD "Options (Networking):" C_RESET "\n" - " --net=MODE Modes: host (default), nat, none, gateway\n" + " --net=MODE Modes: host, nat, none, gateway, ipvlan, " + "macvlan\n" + " --net-parent=IFACE Parent link; blank config auto-detects uplink\n" + " --net-mac=MAC Optional macvlan MAC; blank leaves it unmanaged\n" + " --net-ipam=MODE Addressing: dhcp (default) or static\n" + " --net-address=CIDR Static IPv4 address, e.g. 192.168.1.50/24\n" + " --net-gateway=IP Static IPv4 default gateway\n" " --gateway=NAME Gateway container for --net=gateway\n" " --gateway-net=NAME Gateway LAN name/bridge suffix (default: " "lan)\n" @@ -244,6 +250,47 @@ static int validate_configuration_cli(struct ds_config *cfg) { } } + if (cfg->net_mode == DS_NET_IPVLAN || cfg->net_mode == DS_NET_MACVLAN) { + /* A blank parent means auto-detect the active uplink at start time. */ + if (cfg->net_parent[0] && + (strlen(cfg->net_parent) >= IFNAMSIZ || + strchr(cfg->net_parent, '/') || + strpbrk(cfg->net_parent, " \t\r\n"))) { + ds_error("Invalid parent interface name: %s", cfg->net_parent); + errors++; + } + + if (cfg->net_ipam == DS_NET_IPAM_STATIC) { + char address[sizeof(cfg->net_address)]; + safe_strncpy(address, cfg->net_address, sizeof(address)); + char *slash = strchr(address, '/'); + char *end = NULL; + long prefix = slash ? strtol(slash + 1, &end, 10) : -1; + if (slash) + *slash = '\0'; + struct in_addr parsed; + if (!slash || !end || *end || prefix < 0 || prefix > 32 || + inet_pton(AF_INET, address, &parsed) != 1) { + ds_error("--net-address must be a valid IPv4 CIDR."); + errors++; + } + if (inet_pton(AF_INET, cfg->net_gateway, &parsed) != 1) { + ds_error("--net-gateway must be a valid IPv4 address."); + errors++; + } + } + if (cfg->net_mode == DS_NET_MACVLAN && cfg->net_mac[0]) { + uint8_t mac[6]; + if (ds_parse_mac_address(cfg->net_mac, mac) < 0) { + ds_error("--net-mac must be a unicast MAC such as 02:11:22:33:44:55."); + errors++; + } + } else if (cfg->net_mode == DS_NET_IPVLAN && cfg->net_mac[0]) { + ds_error("--net-mac is only valid with --net=macvlan."); + errors++; + } + } + return (errors > 0) ? -1 : 0; } @@ -308,12 +355,13 @@ static void enforce_nat_safety(struct ds_config *cfg, int argc, char **argv) { } if (cfg->net_mode == DS_NET_NAT || cfg->net_mode == DS_NET_NONE || - cfg->net_mode == DS_NET_GATEWAY) { + cfg->net_mode == DS_NET_GATEWAY || cfg->net_mode == DS_NET_IPVLAN || + cfg->net_mode == DS_NET_MACVLAN) { if (!check_ns(CLONE_NEWNET, "net")) { printf("\n" C_RED C_BOLD "[ FATAL: NETWORK NAMESPACE UNSUPPORTED ]" C_RESET "\n\n"); ds_error("Kernel does not support CLONE_NEWNET (network namespaces)."); - ds_log("Cannot use --net=nat, --net=none, or --net=gateway."); + ds_log("The selected isolated network mode cannot be used."); ds_log("Tip: Use --net=host (default) for shared host networking."); exit(EXIT_FAILURE); } @@ -346,6 +394,35 @@ static void enforce_nat_safety(struct ds_config *cfg, int argc, char **argv) { return; } + if (cfg->net_mode == DS_NET_IPVLAN || cfg->net_mode == DS_NET_MACVLAN) { + char reason[512]; + const char *kind = + cfg->net_mode == DS_NET_IPVLAN ? "ipvlan" : "macvlan"; + if (ds_net_resolve_parent(cfg, reason, sizeof(reason)) < 0) { + printf("\n" C_RED C_BOLD + "[ FATAL: DIRECT L2 PARENT UNAVAILABLE ]" C_RESET "\n\n"); + ds_error("--net=%s could not select a parent interface:\n %s", kind, + reason); + ds_log("Connect a host network or set --net-parent explicitly."); + exit(1); + } + ds_log("[NET] %s", reason); + int probe = ds_nl_probe_parent_capability(cfg->net_parent, kind, reason, + sizeof(reason)); + if (probe < 0) { + printf("\n" C_RED C_BOLD + "[ FATAL: DIRECT L2 NETWORKING UNSUPPORTED ]" C_RESET "\n\n"); + ds_error("--net=%s is unavailable:\n %s", kind, reason); + ds_log("Check that CONFIG_%s=y and that parent '%s' supports this link " + "type.", + cfg->net_mode == DS_NET_IPVLAN ? "IPVLAN" : "MACVLAN", + cfg->net_parent); + exit(1); + } + ds_log("[NET] Kernel capability probe passed: %s", reason); + return; + } + if (cfg->net_mode != DS_NET_NAT) return; @@ -420,6 +497,11 @@ int main(int argc, char **argv) { {"gateway-net", required_argument, 0, 275}, {"gateway-iface", required_argument, 0, 276}, {"gateway-bridge", required_argument, 0, 277}, + {"net-parent", required_argument, 0, 280}, + {"net-ipam", required_argument, 0, 281}, + {"net-address", required_argument, 0, 282}, + {"net-gateway", required_argument, 0, 283}, + {"net-mac", required_argument, 0, 284}, {"reset", no_argument, 0, 256}, {"format", no_argument, 0, 265}, {"memory", required_argument, 0, 266}, @@ -495,6 +577,10 @@ int main(int argc, char **argv) { else if (strcmp(optarg, "gateway") == 0 || strcmp(optarg, "delegated-gateway") == 0) cfg.net_mode = DS_NET_GATEWAY; + else if (strcmp(optarg, "ipvlan") == 0) + cfg.net_mode = DS_NET_IPVLAN; + else if (strcmp(optarg, "macvlan") == 0) + cfg.net_mode = DS_NET_MACVLAN; else { ds_error("Unknown network mode: '%s'. Valid options: host, nat, none, " "gateway", @@ -693,6 +779,30 @@ int main(int argc, char **argv) { case 277: safe_strncpy(cfg.gateway_bridge, optarg, sizeof(cfg.gateway_bridge)); break; + case 280: + safe_strncpy(cfg.net_parent, optarg, sizeof(cfg.net_parent)); + break; + case 281: + if (strcmp(optarg, "dhcp") == 0) + cfg.net_ipam = DS_NET_IPAM_DHCP; + else if (strcmp(optarg, "static") == 0) + cfg.net_ipam = DS_NET_IPAM_STATIC; + else { + ds_error("Unknown --net-ipam value '%s' (expected dhcp or static)", + optarg); + ret = 1; + goto cleanup; + } + break; + case 282: + safe_strncpy(cfg.net_address, optarg, sizeof(cfg.net_address)); + break; + case 283: + safe_strncpy(cfg.net_gateway, optarg, sizeof(cfg.net_gateway)); + break; + case 284: + safe_strncpy(cfg.net_mac, optarg, sizeof(cfg.net_mac)); + break; case 'I': cfg.disable_ipv6 = 1; break; @@ -766,9 +876,13 @@ int main(int argc, char **argv) { else if (strcmp(optarg, "gateway") == 0 || strcmp(optarg, "delegated-gateway") == 0) cli_net_mode = DS_NET_GATEWAY; + else if (strcmp(optarg, "ipvlan") == 0) + cli_net_mode = DS_NET_IPVLAN; + else if (strcmp(optarg, "macvlan") == 0) + cli_net_mode = DS_NET_MACVLAN; else { ds_error("Unknown network mode: '%s'. Valid options: host, nat, none, " - "gateway", + "gateway, ipvlan, macvlan", optarg); ret = 1; goto cleanup; diff --git a/src/monitor.c b/src/monitor.c index 4bffd9c3..de8858b7 100644 --- a/src/monitor.c +++ b/src/monitor.c @@ -456,6 +456,7 @@ reboot_loop:; } close(cfg->net_ready_pipe[0]); + int net_setup_status = 0; if (cfg->net_mode == DS_NET_NAT) { if (setup_veth_host_side(cfg, netns_pid) < 0) { ds_warn("[NET] Monitor: setup_veth_host_side failed - " @@ -470,6 +471,12 @@ reboot_loop:; ds_warn("[NET] Monitor: setup_gateway_veth_side failed - " "container will remain isolated"); } + } else if (cfg->net_mode == DS_NET_IPVLAN || + cfg->net_mode == DS_NET_MACVLAN) { + net_setup_status = setup_parent_link_host_side(cfg, netns_pid); + if (net_setup_status < 0) + ds_warn("[NET] Monitor: direct L2 link setup failed: %s", + strerror(-net_setup_status)); } /* Gateway self-heal: if any running client delegates to THIS container as @@ -491,6 +498,7 @@ reboot_loop:; /* Send handshake to init */ struct ds_net_handshake hs; ds_net_derive_handshake(netns_pid, cfg, &hs); + hs.status = net_setup_status; if (cfg->net_mode == DS_NET_GATEWAY) ds_log("[NET] Monitor: sending DONE (gateway mode: eth0 is wired " "host-side, IP comes from the gateway's DHCP)"); diff --git a/src/net/netlink.c b/src/net/netlink.c index 0a2af65d..12ebf999 100644 --- a/src/net/netlink.c +++ b/src/net/netlink.c @@ -408,6 +408,89 @@ int ds_nl_create_veth(ds_nl_ctx_t *ctx, const char *host, const char *peer) { return ds_nl_talk(ctx, &req.n); } +/* Create an ipvlan L2 or macvlan bridge link attached to a real host link. + * The link is deliberately created in the host namespace first so Android's + * parent ifindex can be referenced, then the caller moves it into the guest. */ +int ds_nl_create_parent_link(ds_nl_ctx_t *ctx, const char *parent, + const char *name, const char *kind) { + int parent_idx = ds_nl_get_ifindex(ctx, parent); + if (parent_idx <= 0) + return -ENODEV; + if (strcmp(kind, "ipvlan") != 0 && strcmp(kind, "macvlan") != 0) + return -EINVAL; + + struct { + struct nlmsghdr n; + struct ifinfomsg i; + char buf[1024]; + } req; + memset(&req, 0, sizeof(req)); + req.n.nlmsg_len = NLMSG_LENGTH(sizeof(struct ifinfomsg)); + req.n.nlmsg_type = RTM_NEWLINK; + req.n.nlmsg_flags = NLM_F_REQUEST | NLM_F_CREATE | NLM_F_EXCL | NLM_F_ACK; + req.i.ifi_family = AF_UNSPEC; + + nl_addattr(&req.n, (int)sizeof(req), IFLA_LINK, &parent_idx, + (int)sizeof(parent_idx)); + nl_addattr(&req.n, (int)sizeof(req), IFLA_IFNAME, name, + (int)strlen(name) + 1); + + struct rtattr *linfo = nl_nest_begin(&req.n, (int)sizeof(req), IFLA_LINKINFO); + nl_addattr(&req.n, (int)sizeof(req), IFLA_INFO_KIND, kind, + (int)strlen(kind) + 1); + struct rtattr *ldata = + nl_nest_begin(&req.n, (int)sizeof(req), IFLA_INFO_DATA); + if (strcmp(kind, "ipvlan") == 0) { + uint16_t mode = IPVLAN_MODE_L2; + nl_addattr(&req.n, (int)sizeof(req), IFLA_IPVLAN_MODE, &mode, + (int)sizeof(mode)); + } else { + uint32_t mode = MACVLAN_MODE_BRIDGE; + nl_addattr(&req.n, (int)sizeof(req), IFLA_MACVLAN_MODE, &mode, + (int)sizeof(mode)); + } + nl_nest_end(&req.n, ldata); + nl_nest_end(&req.n, linfo); + return ds_nl_talk(ctx, &req.n); +} + +int ds_nl_probe_parent_capability(const char *parent, const char *kind, + char *reason, size_t rsz) { + if (!parent || !parent[0] || !kind) { + snprintf(reason, rsz, "A parent interface is required."); + return -EINVAL; + } + + ds_nl_ctx_t *ctx = ds_nl_open(); + if (!ctx) { + snprintf(reason, rsz, "Failed to open NETLINK_ROUTE socket: %s", + strerror(errno)); + return -errno; + } + if (ds_nl_get_ifindex(ctx, parent) <= 0) { + snprintf(reason, rsz, "Parent interface '%s' does not exist.", parent); + ds_nl_close(ctx); + return -ENODEV; + } + + char probe[IFNAMSIZ]; + snprintf(probe, sizeof(probe), "%s%x", + strcmp(kind, "ipvlan") == 0 ? "ds-ci" : "ds-cm", + (unsigned int)getpid()); + int ret = ds_nl_create_parent_link(ctx, parent, probe, kind); + if (ret == 0) + ds_nl_del_link(ctx, probe); + ds_nl_close(ctx); + + if (ret < 0) { + snprintf(reason, rsz, "%s on parent '%s' failed: %s", kind, parent, + strerror(-ret)); + return ret; + } + snprintf(reason, rsz, "OK (%s on %s)", kind, parent); + return 0; +} + /* --------------------------------------------------------------------------- * Attach an interface to a bridge (IFLA_MASTER) * ---------------------------------------------------------------------------*/ diff --git a/src/net/network.c b/src/net/network.c index 523919eb..a909a9aa 100644 --- a/src/net/network.c +++ b/src/net/network.c @@ -46,7 +46,13 @@ static void veth_host_name(const struct ds_config *cfg, pid_t pid, char *buf, * Gateway clients use "ds-q" to match the distinct host-side prefix. */ static void veth_peer_name(const struct ds_config *cfg, pid_t pid, char *buf, size_t sz) { - const char *p = (cfg && cfg->net_mode == DS_NET_GATEWAY) ? "ds-q" : "ds-p"; + const char *p = "ds-p"; + if (cfg && cfg->net_mode == DS_NET_GATEWAY) + p = "ds-q"; + else if (cfg && cfg->net_mode == DS_NET_IPVLAN) + p = "ds-i"; + else if (cfg && cfg->net_mode == DS_NET_MACVLAN) + p = "ds-m"; snprintf(buf, sz, "%s%d", p, (int)pid); } @@ -327,6 +333,7 @@ static int uplink_name_excluded(const char *ifname) { void ds_net_derive_handshake(pid_t init_pid, struct ds_config *cfg, struct ds_net_handshake *hs) { + memset(hs, 0, sizeof(*hs)); veth_peer_name(cfg, init_pid, hs->peer_name, sizeof(hs->peer_name)); /* Use the already-resolved static IP - not the PID-hash fallback. * ip_str is informational on the child side (voided in @@ -897,6 +904,92 @@ int setup_veth_host_side(struct ds_config *cfg, pid_t child_pid) { return 0; } +/* Parse a conventional colon-separated unicast MAC address. All-zero, + * multicast, broadcast, truncated, and trailing-junk forms are rejected. */ +int ds_parse_mac_address(const char *text, uint8_t mac[6]) { + if (!text || !text[0] || !mac) + return -EINVAL; + + unsigned int b[6]; + char extra; + if (sscanf(text, "%2x:%2x:%2x:%2x:%2x:%2x%c", &b[0], &b[1], &b[2], + &b[3], &b[4], &b[5], &extra) != 6) + return -EINVAL; + + int any = 0; + for (size_t i = 0; i < 6; i++) { + if (b[i] > 0xff) + return -EINVAL; + mac[i] = (uint8_t)b[i]; + any |= mac[i]; + } + if (!any || (mac[0] & 0x01)) + return -EINVAL; + return 0; +} + +/* Create an L2 child of an Android interface and move it into the container. + * No bridge, DHCP server, NAT, firewall, address, or route is installed on the + * host. DHCP packets therefore reach the physical LAN unchanged. */ +int setup_parent_link_host_side(struct ds_config *cfg, pid_t child_pid) { + const char *kind = cfg->net_mode == DS_NET_IPVLAN ? "ipvlan" : "macvlan"; + char peer[IFNAMSIZ]; + veth_peer_name(cfg, child_pid, peer, sizeof(peer)); + + ds_nl_ctx_t *ctx = ds_nl_open(); + if (!ctx) + return -errno; + + ds_nl_del_link(ctx, peer); + int ret = ds_nl_create_parent_link(ctx, cfg->net_parent, peer, kind); + if (ret < 0) { + ds_warn("[NET] Failed to create %s link %s on %s: %s", kind, peer, + cfg->net_parent, strerror(-ret)); + ds_nl_close(ctx); + return ret; + } + + /* A user-specified macvlan identity is optional. When blank, do not touch + * the kernel-assigned address. ipvlan shares its parent's MAC. */ + if (cfg->net_mode == DS_NET_MACVLAN && cfg->net_mac[0]) { + uint8_t mac[6]; + if (ds_parse_mac_address(cfg->net_mac, mac) < 0) { + ds_nl_del_link(ctx, peer); + ds_nl_close(ctx); + return -EINVAL; + } + ret = ds_nl_set_mac(ctx, peer, mac); + if (ret < 0) { + ds_warn("[NET] Could not set requested MAC %s on %s: %s", cfg->net_mac, + peer, strerror(-ret)); + ds_nl_del_link(ctx, peer); + ds_nl_close(ctx); + return ret; + } + ds_log("[NET] Set macvlan MAC to %s", cfg->net_mac); + } + + char netns_path[PATH_MAX]; + snprintf(netns_path, sizeof(netns_path), "/proc/%d/ns/net", child_pid); + int netns_fd = open(netns_path, O_RDONLY | O_CLOEXEC); + if (netns_fd < 0) { + ret = -errno; + ds_nl_del_link(ctx, peer); + ds_nl_close(ctx); + return ret; + } + ret = ds_nl_move_to_netns(ctx, peer, netns_fd); + close(netns_fd); + if (ret < 0) + ds_nl_del_link(ctx, peer); + ds_nl_close(ctx); + + if (ret == 0) + ds_log("[NET] Attached %s to parent %s; guest owns IP configuration", kind, + cfg->net_parent); + return ret; +} + /* --------------------------------------------------------------------------- * Gateway segment lock * @@ -1463,6 +1556,67 @@ int setup_veth_child_side_named(struct ds_config *cfg, const char *peer_name, return 0; } +int setup_parent_link_child_side(struct ds_config *cfg, const char *peer_name) { + ds_nl_ctx_t *ctx = ds_nl_open(); + if (!ctx) + return -errno; + + if (peer_name && peer_name[0] && strcmp(peer_name, "eth0") != 0) { + int ret = ds_nl_rename(ctx, peer_name, "eth0"); + if (ret < 0) { + ds_warn("[NET] Child: failed to rename %s to eth0: %s", peer_name, + strerror(-ret)); + ds_nl_close(ctx); + return ret; + } + } + ds_nl_link_up(ctx, "lo"); + int ret = ds_nl_link_up(ctx, "eth0"); + if (ret < 0) { + ds_nl_close(ctx); + return ret; + } + + if (cfg->net_ipam == DS_NET_IPAM_STATIC) { + char cidr[sizeof(cfg->net_address)]; + safe_strncpy(cidr, cfg->net_address, sizeof(cidr)); + char *slash = strchr(cidr, '/'); + if (!slash) { + ds_nl_close(ctx); + return -EINVAL; + } + *slash++ = '\0'; + char *end = NULL; + long prefix = strtol(slash, &end, 10); + struct in_addr addr, gateway; + if (!end || *end || prefix < 0 || prefix > 32 || + inet_pton(AF_INET, cidr, &addr) != 1 || + inet_pton(AF_INET, cfg->net_gateway, &gateway) != 1) { + ds_nl_close(ctx); + return -EINVAL; + } + ret = ds_nl_add_addr4(ctx, "eth0", addr.s_addr, (uint8_t)prefix); + if (ret < 0 && ret != -EEXIST) { + ds_nl_close(ctx); + return ret; + } + int ifindex = ds_nl_get_ifindex(ctx, "eth0"); + ret = ds_nl_add_route4(ctx, 0, 0, gateway.s_addr, ifindex); + if (ret < 0 && ret != -EEXIST) { + ds_nl_close(ctx); + return ret; + } + ds_log("[NET] Child: static address %s via %s configured on eth0", + cfg->net_address, cfg->net_gateway); + } else { + ds_log("[NET] Child: eth0 UP; guest NetworkManager/networkd will request " + "DHCP from the external network"); + } + + ds_nl_close(ctx); + return 0; +} + /* Compatibility wrapper */ /* --------------------------------------------------------------------------- @@ -1491,7 +1645,10 @@ static void setup_resolv_conf(struct ds_config *cfg) { * dnsmasq), advertised in the DHCP lease. Droidspaces must NOT write a * static resolv.conf or it would bypass the gateway's DNS filtering/caching. */ - if (cfg->net_mode == DS_NET_GATEWAY && !cfg->dns_servers[0]) { + if ((cfg->net_mode == DS_NET_GATEWAY || + ((cfg->net_mode == DS_NET_IPVLAN || cfg->net_mode == DS_NET_MACVLAN) && + cfg->net_ipam == DS_NET_IPAM_DHCP)) && + !cfg->dns_servers[0]) { if (is_systemd_rootfs("/")) { /* systemd-resolved consumes the lease and publishes the real resolver. */ target = "/run/systemd/resolve/resolv.conf"; @@ -1500,8 +1657,7 @@ static void setup_resolv_conf(struct ds_config *cfg) { * which writes the gateway-supplied nameserver from the lease. Writing a * hardcoded 1.1.1.1/8.8.8.8 here would silently defeat the gateway's DNS * (adblock, split-horizon, etc.). Pass --dns to override. */ - ds_log("[NET] Gateway: leaving /etc/resolv.conf to the container's DHCP " - "client (gateway owns DNS)"); + ds_log("[NET] Leaving /etc/resolv.conf to the container's DHCP client"); return; } } else { @@ -1541,7 +1697,8 @@ int fix_networking_rootfs(struct ds_config *cfg) { * Gateway mode is policy-owned by OpenWrt, so IPv6 RA/DHCPv6 should be able * to operate inside the application container netns. */ int ipv6_enabled = - ((cfg->net_mode == DS_NET_HOST || cfg->net_mode == DS_NET_GATEWAY) && + ((cfg->net_mode == DS_NET_HOST || cfg->net_mode == DS_NET_GATEWAY || + cfg->net_mode == DS_NET_IPVLAN || cfg->net_mode == DS_NET_MACVLAN) && !cfg->disable_ipv6); if (ipv6_enabled) { snprintf(hosts_content, sizeof(hosts_content), @@ -1818,6 +1975,55 @@ static int find_active_uplink(ds_nl_ctx_t *ctx, char *iface_out, return -ENOENT; } +/* Resolve the parent for direct-L2 modes. A configured value is a strict + * override; an empty value follows the same Android-aware active-uplink + * detection used by NAT (netd default rule, main table, then whitelist). + * The resolved name lives only in this start request, so container.config can + * remain blank and be re-evaluated after Wi-Fi/mobile handoffs. */ +int ds_net_resolve_parent(struct ds_config *cfg, char *reason, + size_t reason_size) { + if (!cfg || (cfg->net_mode != DS_NET_IPVLAN && + cfg->net_mode != DS_NET_MACVLAN)) { + if (reason && reason_size) + snprintf(reason, reason_size, "Direct-L2 network mode is not selected."); + return -EINVAL; + } + + if (cfg->net_parent[0]) { + if (reason && reason_size) + snprintf(reason, reason_size, "Using configured parent '%s'.", + cfg->net_parent); + return 0; + } + + ds_nl_ctx_t *ctx = ds_nl_open(); + if (!ctx) { + int err = errno ? errno : EIO; + if (reason && reason_size) + snprintf(reason, reason_size, "Cannot open route netlink: %s", + strerror(err)); + return -err; + } + + char iface[IFNAMSIZ] = {0}; + int table = 0; + int ret = find_active_uplink(ctx, iface, &table); + ds_nl_close(ctx); + if (ret < 0 || !iface[0]) { + if (reason && reason_size) + snprintf(reason, reason_size, + "No active host uplink with an IPv4 default route was found."); + return ret < 0 ? ret : -ENOENT; + } + + safe_strncpy(cfg->net_parent, iface, sizeof(cfg->net_parent)); + if (reason && reason_size) + snprintf(reason, reason_size, + "Auto-detected parent '%s' from active route table %d.", iface, + table); + return 0; +} + /* Re-probe which uplink is active and update the ip rule if needed. */ static void do_uplink_reprobe(void) { ds_nl_ctx_t *ctx = ds_nl_open(); diff --git a/src/socketd_bridge.c b/src/socketd_bridge.c index 7931c441..4c34aa48 100644 --- a/src/socketd_bridge.c +++ b/src/socketd_bridge.c @@ -740,13 +740,33 @@ static int socketd_validate_start_config(struct ds_config *cfg) { if (check_requirements_hw(cfg->hw_access) < 0) return -1; - if ((cfg->net_mode == DS_NET_NAT || cfg->net_mode == DS_NET_NONE) && + if ((cfg->net_mode == DS_NET_NAT || cfg->net_mode == DS_NET_NONE || + cfg->net_mode == DS_NET_GATEWAY || cfg->net_mode == DS_NET_IPVLAN || + cfg->net_mode == DS_NET_MACVLAN) && !check_ns(CLONE_NEWNET, "net")) { ds_error("Container '%s' requires network namespaces for its net mode", cfg->container_name); return -1; } + if (cfg->net_mode == DS_NET_IPVLAN || cfg->net_mode == DS_NET_MACVLAN) { + char reason[256]; + const char *kind = + cfg->net_mode == DS_NET_IPVLAN ? "ipvlan" : "macvlan"; + if (ds_net_resolve_parent(cfg, reason, sizeof(reason)) < 0) { + ds_error("Container '%s' cannot auto-detect a %s parent: %s", + cfg->container_name, kind, reason); + return -1; + } + ds_log("[NET] %s", reason); + if (ds_nl_probe_parent_capability(cfg->net_parent, kind, reason, + sizeof(reason)) < 0) { + ds_error("Container '%s' cannot use %s: %s", cfg->container_name, kind, + reason); + return -1; + } + } + return 0; } From a99752b160f070404e85270d187066b2f63de16e Mon Sep 17 00:00:00 2001 From: EricMa <307748790@qq.com> Date: Sat, 8 Aug 2026 10:39:17 +0800 Subject: [PATCH 02/10] fix: restore unique direct-L2 DHCP identity --- .../app/src/main/assets/post_extract_fixes.sh | 1 + src/boot.c | 153 ++++++++++++++++-- src/net/network.c | 7 + 3 files changed, 147 insertions(+), 14 deletions(-) diff --git a/Android/app/src/main/assets/post_extract_fixes.sh b/Android/app/src/main/assets/post_extract_fixes.sh index 6b40f306..d8aba1ba 100755 --- a/Android/app/src/main/assets/post_extract_fixes.sh +++ b/Android/app/src/main/assets/post_extract_fixes.sh @@ -214,6 +214,7 @@ IPv6AcceptRA=yes ClientIdentifier=duid UseDNS=yes UseDomains=yes +RequestBroadcast=yes RouteMetric=100 EOF diff --git a/src/boot.c b/src/boot.c index 81f4ccf1..5eb27d74 100644 --- a/src/boot.c +++ b/src/boot.c @@ -9,6 +9,100 @@ #include #include +static int ds_hex_value(unsigned char c) { + if (c >= '0' && c <= '9') + return (int)(c - '0'); + if (c >= 'a' && c <= 'f') + return (int)(c - 'a') + 10; + if (c >= 'A' && c <= 'F') + return (int)(c - 'A') + 10; + return -1; +} + +/* Build a stable DHCP identity for direct-L2 links. + * + * ipvlan shares its lower device's MAC, so ClientIdentifier=mac would make + * every ipvlan container (and Android itself) look like the same DHCP client. + * Use the container UUID as a DUID-UUID instead. Legacy configs without a + * valid UUID get a deterministic fallback derived from the container name; + * the identity must never change merely because the container restarts. + * + * The DHCP hostname gets a stable numeric suffix so cloned/common hostnames + * remain distinguishable without causing a fresh lease on every boot. */ +static void ds_build_direct_dhcp_identity(const struct ds_config *cfg, + char duid_raw[48], uint32_t *iaid, + char dhcp_hostname[64]) { + uint8_t id[16] = {0}; + int valid_uuid = cfg->uuid[0] != '\0' && strlen(cfg->uuid) == DS_UUID_LEN; + + if (valid_uuid) { + for (size_t i = 0; i < sizeof(id); i++) { + int hi = ds_hex_value((unsigned char)cfg->uuid[i * 2]); + int lo = ds_hex_value((unsigned char)cfg->uuid[i * 2 + 1]); + if (hi < 0 || lo < 0) { + valid_uuid = 0; + break; + } + id[i] = (uint8_t)((hi << 4) | lo); + } + } + + if (!valid_uuid) { + const char *seed = cfg->container_name[0] ? cfg->container_name + : cfg->hostname; + uint64_t h1 = UINT64_C(1469598103934665603); + uint64_t h2 = UINT64_C(1099511628211) ^ UINT64_C(0x9e3779b97f4a7c15); + for (const unsigned char *p = (const unsigned char *)seed; *p; p++) { + h1 ^= *p; + h1 *= UINT64_C(1099511628211); + h2 ^= (uint64_t)(*p + 0x9dU); + h2 *= UINT64_C(14029467366897019727); + } + for (size_t i = 0; i < 8; i++) { + id[i] = (uint8_t)(h1 >> (i * 8)); + id[i + 8] = (uint8_t)(h2 >> (i * 8)); + } + } + + snprintf(duid_raw, 48, + "%02x:%02x:%02x:%02x:%02x:%02x:%02x:%02x:" + "%02x:%02x:%02x:%02x:%02x:%02x:%02x:%02x", + id[0], id[1], id[2], id[3], id[4], id[5], id[6], id[7], id[8], + id[9], id[10], id[11], id[12], id[13], id[14], id[15]); + + *iaid = ((uint32_t)id[12] << 24) | ((uint32_t)id[13] << 16) | + ((uint32_t)id[14] << 8) | (uint32_t)id[15]; + if (*iaid == 0) + *iaid = 1; + + uint32_t suffix = 2166136261U; + for (size_t i = 0; i < sizeof(id); i++) { + suffix ^= id[i]; + suffix *= 16777619U; + } + suffix %= 100000U; + + const char *source = cfg->hostname[0] ? cfg->hostname : cfg->container_name; + size_t used = 0; + const size_t base_limit = 57; /* '-' + five digits + NUL => max 63 chars */ + for (const unsigned char *p = (const unsigned char *)source; + *p && used < base_limit; p++) { + unsigned char c = (unsigned char)tolower(*p); + if (isalnum(c)) { + dhcp_hostname[used++] = (char)c; + } else if (used > 0 && dhcp_hostname[used - 1] != '-') { + dhcp_hostname[used++] = '-'; + } + } + while (used > 0 && dhcp_hostname[used - 1] == '-') + used--; + if (used == 0) { + memcpy(dhcp_hostname, "droidspace", sizeof("droidspace") - 1); + used = sizeof("droidspace") - 1; + } + snprintf(dhcp_hostname + used, 64 - used, "-%05u", suffix); +} + /* Per-boot network-service policy. /run overrides stale rootfs drop-ins, so * old images immediately learn about ipvlan/macvlan without being re-extracted. * Host/none and direct-L2 static must keep guest managers stopped; NAT, @@ -46,23 +140,54 @@ static void ds_write_guest_network_policy(const struct ds_config *cfg) { if (direct_dhcp) { mkdir("run/systemd/network", 0755); - const char *network = - "[Match]\n" - "Name=eth0\n\n" - "[Network]\n" - "DHCP=ipv4\n" - "IPv6AcceptRA=yes\n" - "LinkLocalAddressing=ipv6\n\n" - "[DHCPv4]\n" - "ClientIdentifier=mac\n" - "UseDNS=yes\n" - "UseDomains=yes\n" - "RouteMetric=100\n\n" - "[IPv6AcceptRA]\n" - "UseDNS=yes\n"; + char network[2048]; + char duid_raw[48]; + char dhcp_hostname[64]; + char client_identity[256]; + uint32_t iaid; + ds_build_direct_dhcp_identity(cfg, duid_raw, &iaid, dhcp_hostname); + + if (cfg->net_mode == DS_NET_IPVLAN) { + snprintf(client_identity, sizeof(client_identity), + "ClientIdentifier=duid\n" + "DUIDType=uuid\n" + "DUIDRawData=%s\n" + "IAID=%u\n", + duid_raw, iaid); + } else { + snprintf(client_identity, sizeof(client_identity), + "ClientIdentifier=mac\n"); + } + + const char *ipv6_link = cfg->disable_ipv6 + ? "IPv6AcceptRA=no\nLinkLocalAddressing=no\n" + : "IPv6AcceptRA=yes\n" + "LinkLocalAddressing=ipv6\n"; + const char *ipv6_ra = cfg->disable_ipv6 + ? "" + : "\n[IPv6AcceptRA]\nUseDNS=yes\n"; + snprintf(network, sizeof(network), + "[Match]\n" + "Name=eth0\n\n" + "[Network]\n" + "DHCP=ipv4\n" + "%s\n" + "[DHCPv4]\n" + "%s" + "SendHostname=yes\n" + "Hostname=%s\n" + "UseDNS=yes\n" + "UseDomains=yes\n" + "RequestBroadcast=yes\n" + "RouteMetric=100\n" + "%s", + ipv6_link, client_identity, dhcp_hostname, ipv6_ra); if (write_file("run/systemd/network/10-droidspaces-eth0.network", network) < 0) ds_warn("[NET] Cannot write direct-L2 systemd-networkd configuration"); + else + ds_log("[NET] DHCP identity: %s (%s)", dhcp_hostname, + cfg->net_mode == DS_NET_IPVLAN ? "DUID-UUID" : "MAC"); } ds_log("[NET] Guest network services: %s (mode=%d, ipam=%s)", diff --git a/src/net/network.c b/src/net/network.c index a909a9aa..c7c84768 100644 --- a/src/net/network.c +++ b/src/net/network.c @@ -1577,6 +1577,13 @@ int setup_parent_link_child_side(struct ds_config *cfg, const char *peer_name) { return ret; } + /* Enforce the configured IPv6 policy on the direct-L2 interface itself. + * all/default may already have the desired value, but a guest network + * manager can otherwise re-enable IPv6 while applying its link profile. */ + if (write_file("/proc/sys/net/ipv6/conf/eth0/disable_ipv6", + cfg->disable_ipv6 ? "1" : "0") < 0) + ds_warn("[NET] Child: failed to apply IPv6 policy on eth0"); + if (cfg->net_ipam == DS_NET_IPAM_STATIC) { char cidr[sizeof(cfg->net_address)]; safe_strncpy(cidr, cfg->net_address, sizeof(cidr)); From 2282c9ef497fd421d7ca8b282c74119d17722a6e Mon Sep 17 00:00:00 2001 From: EricMa <307748790@qq.com> Date: Sat, 8 Aug 2026 11:41:17 +0800 Subject: [PATCH 03/10] fix: isolate ipvlan IPv6 identities --- src/boot.c | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 80 insertions(+), 5 deletions(-) diff --git a/src/boot.c b/src/boot.c index 5eb27d74..ef1ed88d 100644 --- a/src/boot.c +++ b/src/boot.c @@ -19,6 +19,49 @@ static int ds_hex_value(unsigned char c) { return -1; } +static int ds_file_contains_bytes(const char *path, const char *needle) { + unsigned char buffer[4096 + 64]; + const size_t needle_len = strlen(needle); + size_t carry = 0; + int found = 0; + int fd = open(path, O_RDONLY | O_CLOEXEC); + if (fd < 0 || needle_len == 0 || needle_len > 64) { + if (fd >= 0) + close(fd); + return 0; + } + + for (;;) { + ssize_t n = read(fd, buffer + carry, sizeof(buffer) - carry); + if (n < 0 && errno == EINTR) + continue; + if (n <= 0) + break; + + size_t total = carry + (size_t)n; + for (size_t i = 0; i + needle_len <= total; i++) { + if (memcmp(buffer + i, needle, needle_len) == 0) { + found = 1; + break; + } + } + if (found) + break; + + carry = total < needle_len - 1 ? total : needle_len - 1; + memmove(buffer, buffer + total - carry, carry); + } + + close(fd); + return found; +} + +static int ds_networkd_supports_foreign_nexthops(void) { + static const char setting[] = "ManageForeignNextHops"; + return ds_file_contains_bytes("usr/lib/systemd/systemd-networkd", setting) || + ds_file_contains_bytes("lib/systemd/systemd-networkd", setting); +} + /* Build a stable DHCP identity for direct-L2 links. * * ipvlan shares its lower device's MAC, so ClientIdentifier=mac would make @@ -31,7 +74,8 @@ static int ds_hex_value(unsigned char c) { * remain distinguishable without causing a fresh lease on every boot. */ static void ds_build_direct_dhcp_identity(const struct ds_config *cfg, char duid_raw[48], uint32_t *iaid, - char dhcp_hostname[64]) { + char dhcp_hostname[64], + char ipv6_ra_token[46]) { uint8_t id[16] = {0}; int valid_uuid = cfg->uuid[0] != '\0' && strlen(cfg->uuid) == DS_UUID_LEN; @@ -69,6 +113,12 @@ static void ds_build_direct_dhcp_identity(const struct ds_config *cfg, "%02x:%02x:%02x:%02x:%02x:%02x:%02x:%02x", id[0], id[1], id[2], id[3], id[4], id[5], id[6], id[7], id[8], id[9], id[10], id[11], id[12], id[13], id[14], id[15]); + snprintf(ipv6_ra_token, 46, + "prefixstable," + "%02x%02x%02x%02x%02x%02x%02x%02x" + "%02x%02x%02x%02x%02x%02x%02x%02x", + id[0], id[1], id[2], id[3], id[4], id[5], id[6], id[7], id[8], + id[9], id[10], id[11], id[12], id[13], id[14], id[15]); *iaid = ((uint32_t)id[12] << 24) | ((uint32_t)id[13] << 16) | ((uint32_t)id[14] << 8) | (uint32_t)id[15]; @@ -140,12 +190,31 @@ static void ds_write_guest_network_policy(const struct ds_config *cfg) { if (direct_dhcp) { mkdir("run/systemd/network", 0755); + + /* systemd-networkd 257+ represents RA gateways as kernel nexthop objects. + * Android kernels commonly lack the nexthop netlink API (RTM_NEWNEXTHOP), + * which makes IPv6 RA fail with EOPNOTSUPP. Keep the traditional direct + * gateway route representation when networkd advertises the compatibility + * setting; older releases remain untouched. */ + if (cfg->net_mode == DS_NET_IPVLAN && !cfg->disable_ipv6 && + ds_networkd_supports_foreign_nexthops()) { + mkdir("run/systemd/networkd.conf.d", 0755); + if (write_file("run/systemd/networkd.conf.d/" + "zz-droidspaces-kernel-compat.conf", + "[Network]\nManageForeignNextHops=no\n") < 0) + ds_warn("[NET] Cannot write systemd-networkd kernel compatibility " + "configuration"); + } + char network[2048]; char duid_raw[48]; char dhcp_hostname[64]; + char ipv6_ra_token[46]; char client_identity[256]; + char ipv6_ra[128]; uint32_t iaid; - ds_build_direct_dhcp_identity(cfg, duid_raw, &iaid, dhcp_hostname); + ds_build_direct_dhcp_identity(cfg, duid_raw, &iaid, dhcp_hostname, + ipv6_ra_token); if (cfg->net_mode == DS_NET_IPVLAN) { snprintf(client_identity, sizeof(client_identity), @@ -163,9 +232,15 @@ static void ds_write_guest_network_policy(const struct ds_config *cfg) { ? "IPv6AcceptRA=no\nLinkLocalAddressing=no\n" : "IPv6AcceptRA=yes\n" "LinkLocalAddressing=ipv6\n"; - const char *ipv6_ra = cfg->disable_ipv6 - ? "" - : "\n[IPv6AcceptRA]\nUseDNS=yes\n"; + if (cfg->disable_ipv6) { + ipv6_ra[0] = '\0'; + } else if (cfg->net_mode == DS_NET_IPVLAN) { + snprintf(ipv6_ra, sizeof(ipv6_ra), + "\n[IPv6AcceptRA]\nToken=%s\nUseDNS=yes\n", ipv6_ra_token); + } else { + snprintf(ipv6_ra, sizeof(ipv6_ra), + "\n[IPv6AcceptRA]\nUseDNS=yes\n"); + } snprintf(network, sizeof(network), "[Match]\n" "Name=eth0\n\n" From 59ebaaf80b18cef865d8f9fe0c90a2c1ef28a17e Mon Sep 17 00:00:00 2001 From: EricMa <307748790@qq.com> Date: Sat, 8 Aug 2026 12:49:18 +0800 Subject: [PATCH 04/10] network: add host access for direct L2 modes --- .../app/ui/component/ContainerConfigForm.kt | 23 + .../ui/screen/InstallationSummaryScreen.kt | 9 + .../app/util/ContainerConfigState.kt | 3 + .../droidspaces/app/util/ContainerManager.kt | 8 + .../src/main/res/values-zh-rCN/strings.xml | 7 + Android/app/src/main/res/values/strings.xml | 7 + Makefile | 1 + src/boot.c | 17 + src/config.c | 25 + src/container.c | 24 +- src/documentation.c | 2 + src/include/droidspace.h | 18 + src/main.c | 20 + src/monitor.c | 14 + src/net/host_access.c | 624 ++++++++++++++++++ src/net/netlink.c | 117 ++++ src/net/network.c | 6 + src/socketd_bridge.c | 4 + 18 files changed, 927 insertions(+), 2 deletions(-) create mode 100644 src/net/host_access.c diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt index 32dda592..012e9778 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt @@ -400,6 +400,29 @@ fun ContainerConfigForm( onSelect = { onStateChange(state.copy(netIpam = it)) }, leadingIcon = Icons.Default.NetworkCheck ) + DsDropdown( + label = context.getString(R.string.host_access), + selected = state.hostAccess, + options = listOf("none", "ptp", "shim"), + displayName = { + context.getString(when (it) { + "ptp" -> R.string.host_access_ptp + "shim" -> R.string.host_access_shim + else -> R.string.host_access_none + }) + }, + onSelect = { onStateChange(state.copy(hostAccess = it)) }, + leadingIcon = Icons.Default.SettingsEthernet + ) + Text( + text = context.getString(when (state.hostAccess) { + "ptp" -> R.string.host_access_ptp_explain + "shim" -> R.string.host_access_shim_explain + else -> R.string.host_access_none_explain + }), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) if (state.netIpam == "static") { OutlinedTextField( value = state.netAddress, diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt index 50e60028..c82ab4fa 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt @@ -106,6 +106,15 @@ fun InstallationSummaryScreen( stringResource(if (config.netIpam == "static") R.string.net_ipam_static else R.string.net_ipam_dhcp), Icons.Default.NetworkCheck ) + SummaryItem( + stringResource(R.string.host_access), + stringResource(when (config.hostAccess) { + "ptp" -> R.string.host_access_ptp + "shim" -> R.string.host_access_shim + else -> R.string.host_access_none + }), + Icons.Default.SettingsEthernet + ) } if (config.useSparseImage && config.sparseImageSizeGB != null) { SummaryItem(stringResource(R.string.storage_configuration), "${stringResource(R.string.sparse_image_configuration)} (${config.sparseImageSizeGB}GB)", Icons.Default.Storage) diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerConfigState.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerConfigState.kt index 39d71163..01bc9278 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ContainerConfigState.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerConfigState.kt @@ -19,6 +19,7 @@ data class ContainerConfigState( val netParent: String = "", val netMac: String = "", val netIpam: String = "dhcp", + val hostAccess: String = "none", val netAddress: String = "", val netGateway: String = "", val disableIPv6: Boolean = false, @@ -56,6 +57,7 @@ fun ContainerInfo.toConfigState(): ContainerConfigState = ContainerConfigState( netParent = netParent, netMac = netMac, netIpam = netIpam, + hostAccess = hostAccess, netAddress = netAddress, netGateway = netGateway, disableIPv6 = disableIPv6, @@ -97,6 +99,7 @@ fun ContainerInfo.withConfig(state: ContainerConfigState): ContainerInfo = copy( netParent = state.netParent, netMac = state.netMac, netIpam = state.netIpam, + hostAccess = state.hostAccess, netAddress = state.netAddress, netGateway = state.netGateway, disableIPv6 = state.disableIPv6, diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt index a7f4c040..e732c5e0 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt @@ -33,6 +33,8 @@ data class ContainerInfo( val netParent: String = "", val netMac: String = "", val netIpam: String = "dhcp", + val hostAccess: String = "none", + val hostAccessPtpCidr: String = "", val netAddress: String = "", val netGateway: String = "", val disableIPv6: Boolean = false, @@ -84,6 +86,10 @@ data class ContainerInfo( appendLine("net_parent=$netParent") if (netMode == "macvlan" && netMac.isNotBlank()) appendLine("net_mac=$netMac") appendLine("net_ipam=$netIpam") + appendLine("host_access=$hostAccess") + if (hostAccess == "ptp" && hostAccessPtpCidr.isNotBlank()) { + appendLine("host_access_ptp_cidr=$hostAccessPtpCidr") + } if (netIpam == "static") { appendLine("net_address=$netAddress") appendLine("net_gateway=$netGateway") @@ -336,6 +342,8 @@ object ContainerManager { netParent = configMap["net_parent"] ?: "", netMac = configMap["net_mac"] ?: "", netIpam = configMap["net_ipam"] ?: "dhcp", + hostAccess = configMap["host_access"] ?: "none", + hostAccessPtpCidr = configMap["host_access_ptp_cidr"] ?: "", netAddress = configMap["net_address"] ?: "", netGateway = configMap["net_gateway"] ?: "", disableIPv6 = configMap["disable_ipv6"] == "1", diff --git a/Android/app/src/main/res/values-zh-rCN/strings.xml b/Android/app/src/main/res/values-zh-rCN/strings.xml index ea1dc2ff..c19000e4 100644 --- a/Android/app/src/main/res/values-zh-rCN/strings.xml +++ b/Android/app/src/main/res/values-zh-rCN/strings.xml @@ -493,6 +493,13 @@ DHCP(容器网络管理器) 静态 IPv4 地址 / 前缀 + 宿主互通 + 不启用 + 专用 PTP(推荐) + 直连局域网地址(shim) + 保持 ipvlan/macvlan 默认的宿主隔离行为。 + 增加专用 dshost0 链路,可靠实现宿主与容器互通,不改变容器的局域网地址。 + 通过共享父接口 shim 路由容器的局域网 IPv4;DHCP 地址会自动检测。 默认网关 请输入有效的 IPv4 CIDR 和 IPv4 网关。 IPvlan 与父接口共用 MAC。Droidspaces 会让 systemd-networkd 使用 DUID 客户端标识,但部分路由器只按 MAC 识别租约,可能无法分配独立地址。 diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml index a5cec738..dc6783ff 100644 --- a/Android/app/src/main/res/values/strings.xml +++ b/Android/app/src/main/res/values/strings.xml @@ -254,6 +254,13 @@ DHCP (guest network manager) Static IPv4 address / prefix + Host access + None + Private PTP (recommended) + Direct LAN address (shim) + Keep the normal ipvlan/macvlan host isolation. + Adds a private dshost0 link for reliable host-to-container access without changing the LAN address. + Routes the container’s LAN IPv4 through a shared parent shim. DHCP addresses are detected automatically. Default gateway Enter a valid IPv4 CIDR and IPv4 gateway. IPvlan shares the parent MAC. Droidspaces asks systemd-networkd to use a DUID client ID, but some routers identify leases only by MAC and may not issue separate leases. diff --git a/Makefile b/Makefile index a92194d3..98e4bcc1 100644 --- a/Makefile +++ b/Makefile @@ -42,6 +42,7 @@ SRCS = $(SRC_DIR)/main.c \ $(SRC_DIR)/mount.c \ $(SRC_DIR)/cgroup.c \ $(SRC_DIR)/net/network.c \ + $(SRC_DIR)/net/host_access.c \ $(SRC_DIR)/terminal.c \ $(SRC_DIR)/console.c \ $(SRC_DIR)/pid.c \ diff --git a/src/boot.c b/src/boot.c index ef1ed88d..146d1fcf 100644 --- a/src/boot.c +++ b/src/boot.c @@ -188,6 +188,23 @@ static void ds_write_guest_network_policy(const struct ds_config *cfg) { ds_warn("[NET] Cannot write systemd network policy %s", file); } + /* dshost0 is a runtime-owned point-to-point control link. The backend + * assigns both ends before init starts; guest managers must leave it alone + * while continuing to manage the direct-L2 eth0 normally. */ + if (cfg->host_access == DS_HOST_ACCESS_PTP) { + mkdir("run/systemd/network", 0755); + if (write_file("run/systemd/network/05-droidspaces-host-access.network", + "[Match]\nName=dshost0\n\n[Link]\nUnmanaged=yes\n") < 0) + ds_warn("[NET] Cannot write dshost0 systemd-networkd policy"); + + mkdir("run/NetworkManager", 0755); + mkdir("run/NetworkManager/conf.d", 0755); + if (write_file("run/NetworkManager/conf.d/" + "05-droidspaces-host-access.conf", + "[keyfile]\nunmanaged-devices=interface-name:dshost0\n") < 0) + ds_warn("[NET] Cannot write dshost0 NetworkManager policy"); + } + if (direct_dhcp) { mkdir("run/systemd/network", 0755); diff --git a/src/config.c b/src/config.c index 763d2eb1..8d8cfd79 100644 --- a/src/config.c +++ b/src/config.c @@ -386,6 +386,18 @@ int ds_config_load(const char *config_path, struct ds_config *cfg) { cfg->net_ipam = DS_NET_IPAM_DHCP; else ds_warn("config: unknown net_ipam '%s'; using dhcp", val); + } else if (strcmp(key, "host_access") == 0) { + if (strcmp(val, "ptp") == 0) + cfg->host_access = DS_HOST_ACCESS_PTP; + else if (strcmp(val, "shim") == 0) + cfg->host_access = DS_HOST_ACCESS_SHIM; + else if (strcmp(val, "none") == 0) + cfg->host_access = DS_HOST_ACCESS_NONE; + else + ds_warn("config: unknown host_access '%s'; using none", val); + } else if (strcmp(key, "host_access_ptp_cidr") == 0) { + safe_strncpy(cfg->host_access_ptp_cidr, val, + sizeof(cfg->host_access_ptp_cidr)); } else if (strcmp(key, "net_parent") == 0) { if (strlen(val) < IFNAMSIZ) safe_strncpy(cfg->net_parent, val, sizeof(cfg->net_parent)); @@ -745,6 +757,14 @@ static void ds_config_serialize_known(FILE *f, struct ds_config *cfg) { fprintf(f, "net_parent=%s\n", cfg->net_parent); fprintf(f, "net_ipam=%s\n", cfg->net_ipam == DS_NET_IPAM_STATIC ? "static" : "dhcp"); + fprintf(f, "host_access=%s\n", + cfg->host_access == DS_HOST_ACCESS_PTP + ? "ptp" + : (cfg->host_access == DS_HOST_ACCESS_SHIM ? "shim" : + "none")); + if (cfg->host_access == DS_HOST_ACCESS_PTP && + cfg->host_access_ptp_cidr[0]) + fprintf(f, "host_access_ptp_cidr=%s\n", cfg->host_access_ptp_cidr); if (cfg->net_mode == DS_NET_MACVLAN && cfg->net_mac[0]) fprintf(f, "net_mac=%s\n", cfg->net_mac); if (cfg->net_ipam == DS_NET_IPAM_STATIC) { @@ -985,6 +1005,11 @@ int ds_config_validate(struct ds_config *cfg) { ds_parse_mac_address(cfg->net_mac, (uint8_t[6]){0}) < 0) || (cfg->net_mode == DS_NET_IPVLAN && cfg->net_mac[0])) errors++; + if (cfg->host_access < DS_HOST_ACCESS_NONE || + cfg->host_access > DS_HOST_ACCESS_SHIM) + errors++; + } else if (cfg->host_access != DS_HOST_ACCESS_NONE) { + errors++; } return (errors > 0) ? -1 : 0; diff --git a/src/container.c b/src/container.c index 88731229..343e700d 100644 --- a/src/container.c +++ b/src/container.c @@ -265,7 +265,10 @@ void cleanup_container_resources(struct ds_config *cfg, pid_t pid, } /* Network cleanup: remove host veth and owned network state */ - if (cfg->net_mode == DS_NET_NAT || cfg->net_mode == DS_NET_GATEWAY) { + if (cfg->net_mode == DS_NET_NAT || cfg->net_mode == DS_NET_GATEWAY || + ((cfg->net_mode == DS_NET_IPVLAN || + cfg->net_mode == DS_NET_MACVLAN) && + cfg->host_access != DS_HOST_ACCESS_NONE)) { ds_net_cleanup(cfg, pid > 0 ? pid : cfg->container_pid); } @@ -542,6 +545,10 @@ int start_rootfs(struct ds_config *cfg) { * Only relevant for NAT mode -- host/none modes skip this cleanly. */ if (cfg->net_mode == DS_NET_NAT) ds_net_resolve_static_ip(cfg); + if ((cfg->net_mode == DS_NET_IPVLAN || + cfg->net_mode == DS_NET_MACVLAN) && + cfg->host_access == DS_HOST_ACCESS_PTP) + ds_host_access_resolve_ptp(cfg); /* Persist UUID and resolved static_nat_ip (for NAT) to config immediately * so disk always matches the running container. CLI overrides (e.g. -f) @@ -1631,6 +1638,14 @@ int show_info(struct ds_config *cfg, int trust_cfg_pid) { printf("NET_PARENT=%s\n", cfg->net_parent); printf("NET_IPAM=%s\n", cfg->net_ipam == DS_NET_IPAM_STATIC ? "static" : "dhcp"); + printf("HOST_ACCESS=%s\n", + cfg->host_access == DS_HOST_ACCESS_PTP + ? "ptp" + : (cfg->host_access == DS_HOST_ACCESS_SHIM ? "shim" : + "none")); + if (cfg->host_access == DS_HOST_ACCESS_PTP && + cfg->host_access_ptp_cidr[0]) + printf("HOST_ACCESS_PTP_CIDR=%s\n", cfg->host_access_ptp_cidr); if (cfg->net_ipam == DS_NET_IPAM_STATIC) { printf("NET_ADDRESS=%s\n", cfg->net_address); printf("NET_GATEWAY=%s\n", cfg->net_gateway); @@ -1797,7 +1812,12 @@ int show_info(struct ds_config *cfg, int trust_cfg_pid) { if (cfg->net_ipam == DS_NET_IPAM_STATIC) printf(" (%s via %s)", cfg->net_address, cfg->net_gateway); printf("\n"); - feat_count += 2; + printf(" Host access: %s\n", + cfg->host_access == DS_HOST_ACCESS_PTP + ? "private PTP" + : (cfg->host_access == DS_HOST_ACCESS_SHIM ? "LAN shim" : + "none")); + feat_count += 3; } if (cfg->net_mode == DS_NET_NAT) { diff --git a/src/documentation.c b/src/documentation.c index 75993454..3260b5af 100644 --- a/src/documentation.c +++ b/src/documentation.c @@ -296,6 +296,8 @@ static void print_page(int page, const char *bin) { p_printf(" --net-parent=IFACE Parent, e.g. wlan0/eth0\n"); p_printf(" --net-ipam=dhcp Guest network manager uses " "upstream DHCP\n"); + p_printf(" --host-access=none|ptp|shim Optional host/container " + "reachability\n"); p_printf(" --net-ipam=static --net-address=A/P --net-gateway=G\n"); p_printf(" No Droidspaces bridge, NAT, or embedded DHCP server is used.\n\n"); diff --git a/src/include/droidspace.h b/src/include/droidspace.h index 14bd3ded..8749834c 100644 --- a/src/include/droidspace.h +++ b/src/include/droidspace.h @@ -214,6 +214,12 @@ enum ds_net_ipam { DS_NET_IPAM_STATIC, /* runtime assigns net_address/gateway */ }; +enum ds_host_access { + DS_HOST_ACCESS_NONE = 0, /* direct-L2 keeps normal host isolation */ + DS_HOST_ACCESS_PTP, /* private host<->guest veth control link */ + DS_HOST_ACCESS_SHIM, /* shared parent child + per-guest /32 route */ +}; + /* Opaque RTNETLINK context - defined in ds_netlink.c */ typedef struct ds_nl_ctx ds_nl_ctx_t; @@ -356,10 +362,12 @@ struct ds_config { char dns_servers[1024]; /* --dns= (comma/space separated) */ enum ds_net_mode net_mode; /* --net=host|nat|none|gateway|... */ enum ds_net_ipam net_ipam; /* --net-ipam=dhcp|static */ + enum ds_host_access host_access; /* --host-access=none|ptp|shim */ char net_parent[IFNAMSIZ]; /* --net-parent=IFACE */ char net_mac[18]; /* --net-mac=XX:XX:XX:XX:XX:XX */ char net_address[32]; /* --net-address=IPv4/PREFIX */ char net_gateway[INET_ADDRSTRLEN]; /* --net-gateway=IPv4 */ + char host_access_ptp_cidr[32]; /* internal stable 169.254.x.x/30 */ char dns_server_content[1024]; /* In-memory DNS config for boot */ char gateway_container[256]; /* --gateway=NAME for gateway mode */ char gateway_net[64]; /* --gateway-net=NAME (default: lan) */ @@ -724,6 +732,10 @@ int setup_gateway_veth_side(struct ds_config *cfg, pid_t child_pid); /* Direct L2 lifecycle for ipvlan/macvlan modes. */ int setup_parent_link_host_side(struct ds_config *cfg, pid_t child_pid); +int ds_host_access_setup(struct ds_config *cfg, pid_t child_pid); +void ds_host_access_refresh(struct ds_config *cfg, pid_t child_pid); +void ds_host_access_cleanup(struct ds_config *cfg, pid_t child_pid); +void ds_host_access_resolve_ptp(struct ds_config *cfg); /* Resolve a blank ipvlan/macvlan parent to the host's active uplink. An * explicitly configured parent is preserved. */ int ds_net_resolve_parent(struct ds_config *cfg, char *reason, size_t reason_size); @@ -778,8 +790,14 @@ int ds_nl_rename(ds_nl_ctx_t *ctx, const char *ifname, const char *newname); int ds_nl_set_mac(ds_nl_ctx_t *ctx, const char *ifname, const uint8_t mac[6]); int ds_nl_add_addr4(ds_nl_ctx_t *ctx, const char *ifname, uint32_t ip_be, uint8_t prefix); +int ds_nl_del_addr4(ds_nl_ctx_t *ctx, const char *ifname, uint32_t ip_be, + uint8_t prefix); +int ds_nl_get_addr4(ds_nl_ctx_t *ctx, const char *ifname, uint32_t *ip_be, + uint8_t *prefix); int ds_nl_add_route4(ds_nl_ctx_t *ctx, uint32_t dst_be, uint8_t dst_len, uint32_t gw_be, int oif_idx); +int ds_nl_del_route4(ds_nl_ctx_t *ctx, uint32_t dst_be, uint8_t dst_len, + uint32_t gw_be, int oif_idx); int ds_nl_move_to_netns(ds_nl_ctx_t *ctx, const char *ifname, int netns_fd); int ds_nl_move_to_netns_named(ds_nl_ctx_t *ctx, const char *ifname, int netns_fd, const char *newname); diff --git a/src/main.c b/src/main.c index 9dee8144..127a4da6 100644 --- a/src/main.c +++ b/src/main.c @@ -60,6 +60,7 @@ void print_usage(void) { " --net-parent=IFACE Parent link; blank config auto-detects uplink\n" " --net-mac=MAC Optional macvlan MAC; blank leaves it unmanaged\n" " --net-ipam=MODE Addressing: dhcp (default) or static\n" + " --host-access=MODE Host reachability: none, ptp, or shim\n" " --net-address=CIDR Static IPv4 address, e.g. 192.168.1.50/24\n" " --net-gateway=IP Static IPv4 default gateway\n" " --gateway=NAME Gateway container for --net=gateway\n" @@ -289,6 +290,9 @@ static int validate_configuration_cli(struct ds_config *cfg) { ds_error("--net-mac is only valid with --net=macvlan."); errors++; } + } else if (cfg->host_access != DS_HOST_ACCESS_NONE) { + ds_error("--host-access is only valid with --net=ipvlan or macvlan."); + errors++; } return (errors > 0) ? -1 : 0; @@ -502,6 +506,7 @@ int main(int argc, char **argv) { {"net-address", required_argument, 0, 282}, {"net-gateway", required_argument, 0, 283}, {"net-mac", required_argument, 0, 284}, + {"host-access", required_argument, 0, 285}, {"reset", no_argument, 0, 256}, {"format", no_argument, 0, 265}, {"memory", required_argument, 0, 266}, @@ -803,6 +808,21 @@ int main(int argc, char **argv) { case 284: safe_strncpy(cfg.net_mac, optarg, sizeof(cfg.net_mac)); break; + case 285: + if (strcmp(optarg, "none") == 0) + cfg.host_access = DS_HOST_ACCESS_NONE; + else if (strcmp(optarg, "ptp") == 0) + cfg.host_access = DS_HOST_ACCESS_PTP; + else if (strcmp(optarg, "shim") == 0) + cfg.host_access = DS_HOST_ACCESS_SHIM; + else { + ds_error("Unknown --host-access value '%s' (expected none, ptp, or " + "shim)", + optarg); + ret = 1; + goto cleanup; + } + break; case 'I': cfg.disable_ipv6 = 1; break; diff --git a/src/monitor.c b/src/monitor.c index de8858b7..ffa6dc24 100644 --- a/src/monitor.c +++ b/src/monitor.c @@ -477,6 +477,12 @@ reboot_loop:; if (net_setup_status < 0) ds_warn("[NET] Monitor: direct L2 link setup failed: %s", strerror(-net_setup_status)); + else if (cfg->host_access != DS_HOST_ACCESS_NONE) { + int ha_ret = ds_host_access_setup(cfg, netns_pid); + if (ha_ret < 0) + ds_warn("[NET] Monitor: host access setup deferred: %s", + strerror(-ha_ret)); + } } /* Gateway self-heal: if any running client delegates to THIS container as @@ -554,6 +560,7 @@ reboot_loop:; sigaddset(&mask, SIGCHLD); sigprocmask(SIG_BLOCK, &mask, NULL); int sfd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC); + unsigned int host_access_ticks = 0; while (1) { pid_t r = waitpid(mid_pid, &status, WNOHANG); @@ -578,6 +585,13 @@ reboot_loop:; } ds_virtualize_update(cfg); + if ((cfg->net_mode == DS_NET_IPVLAN || + cfg->net_mode == DS_NET_MACVLAN) && + cfg->host_access != DS_HOST_ACCESS_NONE && + ++host_access_ticks >= 10) { + ds_host_access_refresh(cfg, cfg->container_pid); + host_access_ticks = 0; + } /* Poll the signalfd and, in background mode, the console PTY master. * poll() wakes immediately when the master becomes readable, so draining diff --git a/src/net/host_access.c b/src/net/host_access.c new file mode 100644 index 00000000..0e1f25aa --- /dev/null +++ b/src/net/host_access.c @@ -0,0 +1,624 @@ +/* + * Direct-L2 host access for ipvlan/macvlan containers. + * + * PTP gives every container a private veth /30. SHIM creates one shared + * ipvlan/macvlan child per parent+kind, borrows the parent's IPv4 as /32, and + * installs a host route for every live container address. + */ +#include "droidspace.h" +#include +#include +#include +#include + +#define DS_HA_PTP_POOL "169.254.240.0" +#define DS_HA_PTP_POOL_PREFIX 20 +#define DS_HA_PTP_LINK_PREFIX 30 +#define DS_HA_RULE_PRIORITY 6080 +#define DS_HA_GUEST_IF "dshost0" + +struct ha_state { + char mode[8]; + char shim[IFNAMSIZ]; + char parent[IFNAMSIZ]; + char guest_ip[INET_ADDRSTRLEN]; + char host_ip[INET_ADDRSTRLEN]; + pid_t pid; +}; + +static uint32_t ha_hash(const char *s) { + uint32_t h = 5381; + if (!s) + return h; + while (*s) + h = ((h << 5) + h) ^ (unsigned char)*s++; + return h; +} + +static const char *ha_key(const struct ds_config *cfg) { + return cfg->uuid[0] ? cfg->uuid : cfg->container_name; +} + +static void ha_ptp_names(const struct ds_config *cfg, char host[IFNAMSIZ], + char peer[IFNAMSIZ]) { + uint32_t h = ha_hash(ha_key(cfg)); + snprintf(host, IFNAMSIZ, "ds-pt%08x", h); + snprintf(peer, IFNAMSIZ, "ds-pg%08x", h); +} + +static void ha_shim_name(const struct ds_config *cfg, char shim[IFNAMSIZ]) { + char key[64]; + snprintf(key, sizeof(key), "%s:%s", + cfg->net_mode == DS_NET_IPVLAN ? "ipvlan" : "macvlan", + cfg->net_parent); + snprintf(shim, IFNAMSIZ, "ds-sh%08x", ha_hash(key)); +} + +static void ha_state_path(const struct ds_config *cfg, char *path, size_t size) { + char safe[256]; + sanitize_container_name(ha_key(cfg), safe, sizeof(safe)); + snprintf(path, size, "%.3800s/ha_%.200s.state", get_net_dir(), safe); +} + +static void ha_stop_path(const struct ds_config *cfg, char *path, size_t size) { + char safe[256]; + sanitize_container_name(ha_key(cfg), safe, sizeof(safe)); + snprintf(path, size, "%.3800s/ha_%.200s.stop", get_net_dir(), safe); +} + +static int ha_is_stopping(const struct ds_config *cfg) { + char path[PATH_MAX]; + ha_stop_path(cfg, path, sizeof(path)); + return access(path, F_OK) == 0; +} + +static int ha_lock(const char *key) { + char path[PATH_MAX]; + snprintf(path, sizeof(path), "%.3800s/ha_%.32s.lock", get_net_dir(), key); + int fd = open(path, O_CREAT | O_RDWR | O_CLOEXEC, 0600); + if (fd < 0) + return -1; + if (flock(fd, LOCK_EX) < 0) { + close(fd); + return -1; + } + return fd; +} + +static void ha_unlock(int fd) { + if (fd >= 0) { + (void)flock(fd, LOCK_UN); + close(fd); + } +} + +static void ha_write_state(const struct ds_config *cfg, + const struct ha_state *state) { + char path[PATH_MAX]; + char content[512]; + ha_state_path(cfg, path, sizeof(path)); + snprintf(content, sizeof(content), + "mode=%s\npid=%d\nshim=%s\nparent=%s\nguest_ip=%s\nhost_ip=%s\n", + state->mode, (int)state->pid, state->shim, state->parent, + state->guest_ip, state->host_ip); + if (write_file_atomic(path, content) < 0) + ds_warn("[NET] Host access: failed to save runtime state: %s", + strerror(errno)); +} + +static int ha_read_state_path(const char *path, struct ha_state *state) { + FILE *f = fopen(path, "re"); + if (!f) + return -1; + memset(state, 0, sizeof(*state)); + char line[128]; + while (fgets(line, sizeof(line), f)) { + char *nl = strpbrk(line, "\r\n"); + if (nl) + *nl = '\0'; + char *eq = strchr(line, '='); + if (!eq) + continue; + *eq++ = '\0'; + if (strcmp(line, "mode") == 0) + safe_strncpy(state->mode, eq, sizeof(state->mode)); + else if (strcmp(line, "pid") == 0) + state->pid = (pid_t)strtol(eq, NULL, 10); + else if (strcmp(line, "shim") == 0) + safe_strncpy(state->shim, eq, sizeof(state->shim)); + else if (strcmp(line, "parent") == 0) + safe_strncpy(state->parent, eq, sizeof(state->parent)); + else if (strcmp(line, "guest_ip") == 0) + safe_strncpy(state->guest_ip, eq, sizeof(state->guest_ip)); + else if (strcmp(line, "host_ip") == 0) + safe_strncpy(state->host_ip, eq, sizeof(state->host_ip)); + } + fclose(f); + return 0; +} + +static int ha_read_state(const struct ds_config *cfg, struct ha_state *state) { + char path[PATH_MAX]; + ha_state_path(cfg, path, sizeof(path)); + return ha_read_state_path(path, state); +} + +static int ha_state_equal(const struct ha_state *a, + const struct ha_state *b) { + return strcmp(a->mode, b->mode) == 0 && strcmp(a->shim, b->shim) == 0 && + strcmp(a->parent, b->parent) == 0 && + strcmp(a->guest_ip, b->guest_ip) == 0 && + strcmp(a->host_ip, b->host_ip) == 0 && a->pid == b->pid; +} + +static int ha_write_disable_ipv6(const char *ifname) { + char path[PATH_MAX]; + snprintf(path, sizeof(path), "/proc/sys/net/ipv6/conf/%s/disable_ipv6", + ifname); + int fd = open(path, O_WRONLY | O_CLOEXEC); + if (fd < 0) + return errno == ENOENT ? 0 : -errno; + ssize_t n = write(fd, "1\n", 2); + int saved = errno; + close(fd); + return n == 2 ? 0 : -saved; +} + +static int ha_parse_ptp_cidr(const char *cidr, uint32_t *network_be) { + if (!cidr || !cidr[0]) + return -EINVAL; + char copy[32]; + safe_strncpy(copy, cidr, sizeof(copy)); + char *slash = strchr(copy, '/'); + if (!slash || strcmp(slash, "/30") != 0) + return -EINVAL; + *slash = '\0'; + struct in_addr addr; + struct in_addr pool; + if (inet_pton(AF_INET, copy, &addr) != 1 || + inet_pton(AF_INET, DS_HA_PTP_POOL, &pool) != 1) + return -EINVAL; + uint32_t host = ntohl(addr.s_addr); + uint32_t pool_host = ntohl(pool.s_addr); + uint32_t pool_mask = 0xffffffffu << (32 - DS_HA_PTP_POOL_PREFIX); + if ((host & pool_mask) != (pool_host & pool_mask) || (host & 3u) != 0) + return -EINVAL; + if (network_be) + *network_be = addr.s_addr; + return 0; +} + +static int ha_ptp_collision(const char *cidr, const char *exclude_name) { + char dir_path[PATH_MAX]; + snprintf(dir_path, sizeof(dir_path), "%s/Containers", get_workspace_dir()); + DIR *dir = opendir(dir_path); + if (!dir) + return 0; + char safe_exclude[256] = {0}; + if (exclude_name && exclude_name[0]) + sanitize_container_name(exclude_name, safe_exclude, sizeof(safe_exclude)); + int collision = 0; + struct dirent *ent; + while ((ent = readdir(dir)) != NULL && !collision) { + if (ent->d_name[0] == '.' || + (safe_exclude[0] && strcmp(ent->d_name, safe_exclude) == 0)) + continue; + char config_path[PATH_MAX + NAME_MAX + 32]; + snprintf(config_path, sizeof(config_path), "%s/%s/container.config", + dir_path, ent->d_name); + struct ds_config other = {0}; + other.net_ready_pipe[0] = other.net_ready_pipe[1] = -1; + other.net_done_pipe[0] = other.net_done_pipe[1] = -1; + if (ds_config_load(config_path, &other) == 0) { + if (other.host_access == DS_HOST_ACCESS_PTP && + strcmp(other.host_access_ptp_cidr, cidr) == 0) + collision = 1; + ds_config_free(&other); + } + } + closedir(dir); + return collision; +} + +void ds_host_access_resolve_ptp(struct ds_config *cfg) { + if (!cfg || cfg->host_access != DS_HOST_ACCESS_PTP) + return; + int lock = ha_lock("ptp-pool"); + if (ha_parse_ptp_cidr(cfg->host_access_ptp_cidr, NULL) == 0 && + !ha_ptp_collision(cfg->host_access_ptp_cidr, cfg->container_name)) { + ha_unlock(lock); + return; + } + + struct in_addr pool; + if (inet_pton(AF_INET, DS_HA_PTP_POOL, &pool) != 1) { + ha_unlock(lock); + return; + } + uint32_t base = ntohl(pool.s_addr); + unsigned int slots = 1u << (DS_HA_PTP_LINK_PREFIX - DS_HA_PTP_POOL_PREFIX); + unsigned int first = ha_hash(ha_key(cfg)) % slots; + for (unsigned int n = 0; n < slots; n++) { + struct in_addr candidate = {.s_addr = htonl(base + ((first + n) % slots) * 4u)}; + char ip[INET_ADDRSTRLEN]; + char cidr[32]; + if (!inet_ntop(AF_INET, &candidate, ip, sizeof(ip))) + continue; + snprintf(cidr, sizeof(cidr), "%s/30", ip); + if (!ha_ptp_collision(cidr, cfg->container_name)) { + safe_strncpy(cfg->host_access_ptp_cidr, cidr, + sizeof(cfg->host_access_ptp_cidr)); + ds_log("[NET] Host access PTP: reserved %s for %s", cidr, + cfg->container_name); + ha_unlock(lock); + return; + } + } + cfg->host_access_ptp_cidr[0] = '\0'; + ds_warn("[NET] Host access PTP: address pool is exhausted"); + ha_unlock(lock); +} + +static int ha_configure_netns_ptp(pid_t pid, uint32_t guest_be) { + int self_fd = open("/proc/self/ns/net", O_RDONLY | O_CLOEXEC); + if (self_fd < 0) + return -errno; + char path[64]; + snprintf(path, sizeof(path), "/proc/%d/ns/net", (int)pid); + int target_fd = open(path, O_RDONLY | O_CLOEXEC); + if (target_fd < 0) { + int ret = -errno; + close(self_fd); + return ret; + } + int ret = 0; + if (setns(target_fd, CLONE_NEWNET) < 0) { + ret = -errno; + goto restore; + } + ds_nl_ctx_t *ctx = ds_nl_open(); + if (!ctx) { + ret = -errno; + goto restore; + } + ret = ds_nl_add_addr4(ctx, DS_HA_GUEST_IF, guest_be, + DS_HA_PTP_LINK_PREFIX); + if (ret == 0) + ret = ds_nl_link_up(ctx, DS_HA_GUEST_IF); + if (ret == 0) + (void)ha_write_disable_ipv6(DS_HA_GUEST_IF); + ds_nl_close(ctx); +restore: + if (setns(self_fd, CLONE_NEWNET) < 0 && ret == 0) + ret = -errno; + close(target_fd); + close(self_fd); + return ret; +} + +static int ha_get_guest_ip(pid_t pid, uint32_t *ip_be) { + int self_fd = open("/proc/self/ns/net", O_RDONLY | O_CLOEXEC); + if (self_fd < 0) + return -errno; + char path[64]; + snprintf(path, sizeof(path), "/proc/%d/ns/net", (int)pid); + int target_fd = open(path, O_RDONLY | O_CLOEXEC); + if (target_fd < 0) { + int ret = -errno; + close(self_fd); + return ret; + } + int ret = 0; + if (setns(target_fd, CLONE_NEWNET) < 0) { + ret = -errno; + goto restore; + } + ds_nl_ctx_t *ctx = ds_nl_open(); + if (!ctx) + ret = -errno; + else { + ret = ds_nl_get_addr4(ctx, "eth0", ip_be, NULL); + ds_nl_close(ctx); + } +restore: + if (setns(self_fd, CLONE_NEWNET) < 0 && ret == 0) + ret = -errno; + close(target_fd); + close(self_fd); + return ret; +} + +static int ha_setup_ptp(struct ds_config *cfg, pid_t pid) { + uint32_t network_be; + if (ha_parse_ptp_cidr(cfg->host_access_ptp_cidr, &network_be) < 0) + return -EINVAL; + uint32_t network = ntohl(network_be); + uint32_t host_be = htonl(network + 1u); + uint32_t guest_be = htonl(network + 2u); + char host[IFNAMSIZ], peer[IFNAMSIZ]; + ha_ptp_names(cfg, host, peer); + int lock = ha_lock(host); + if (ha_is_stopping(cfg)) { + ha_unlock(lock); + return -ECANCELED; + } + ds_nl_ctx_t *ctx = ds_nl_open(); + if (!ctx) { + ha_unlock(lock); + return -errno; + } + + struct ha_state old = {0}; + int have_old = ha_read_state(cfg, &old) == 0; + int created = 0; + int ret = 0; + if (!ds_nl_link_exists(ctx, host)) { + ret = ds_nl_create_veth(ctx, host, peer); + if (ret < 0 && ret != -EEXIST) + goto out; + created = 1; + char ns_path[64]; + snprintf(ns_path, sizeof(ns_path), "/proc/%d/ns/net", (int)pid); + int ns_fd = open(ns_path, O_RDONLY | O_CLOEXEC); + if (ns_fd < 0) { + ret = -errno; + goto out; + } + ret = ds_nl_move_to_netns_named(ctx, peer, ns_fd, DS_HA_GUEST_IF); + close(ns_fd); + if (ret < 0) + goto out; + } + ret = ds_nl_add_addr4(ctx, host, host_be, DS_HA_PTP_LINK_PREFIX); + if (ret == 0) + ret = ds_nl_link_up(ctx, host); + if (ret == 0) + (void)ha_write_disable_ipv6(host); + if (ret == 0) + ret = ds_nl_add_rule4(ctx, 0, 0, inet_addr(DS_HA_PTP_POOL), + DS_HA_PTP_POOL_PREFIX, RT_TABLE_MAIN, + DS_HA_RULE_PRIORITY); + if (ret == 0) + ret = ha_configure_netns_ptp(pid, guest_be); + if (ret == 0) { + struct ha_state state = {0}; + safe_strncpy(state.mode, "ptp", sizeof(state.mode)); + safe_strncpy(state.shim, host, sizeof(state.shim)); + state.pid = pid; + if (!have_old || !ha_state_equal(&old, &state)) + ha_write_state(cfg, &state); + if (!have_old || created) + ds_log("[NET] Host access PTP ready: host=%s guest=%s (%s)", host, + DS_HA_GUEST_IF, cfg->host_access_ptp_cidr); + } +out: + if (ret < 0) + ds_nl_del_link(ctx, host); + ds_nl_close(ctx); + ha_unlock(lock); + return ret; +} + +static int ha_setup_shim(struct ds_config *cfg, pid_t pid, int discover_guest) { + char shim[IFNAMSIZ]; + ha_shim_name(cfg, shim); + int lock = ha_lock(shim); + if (ha_is_stopping(cfg)) { + ha_unlock(lock); + return -ECANCELED; + } + ds_nl_ctx_t *ctx = ds_nl_open(); + if (!ctx) { + ha_unlock(lock); + return -errno; + } + + uint32_t host_be = 0; + int ret = ds_nl_get_addr4(ctx, cfg->net_parent, &host_be, NULL); + if (ret < 0) { + ds_nl_close(ctx); + ha_unlock(lock); + return ret; + } + + struct ha_state old = {0}; + int have_old = ha_read_state(cfg, &old) == 0; + if (!ds_nl_link_exists(ctx, shim)) { + const char *kind = + cfg->net_mode == DS_NET_IPVLAN ? "ipvlan" : "macvlan"; + ret = ds_nl_create_parent_link(ctx, cfg->net_parent, shim, kind); + if (ret < 0 && ret != -EEXIST) + goto out; + } + int shim_idx = ds_nl_get_ifindex(ctx, shim); + if (shim_idx <= 0) { + ret = -ENODEV; + goto out; + } + ret = ds_nl_link_up(ctx, shim); + if (ret < 0) + goto out; + (void)ha_write_disable_ipv6(shim); + + char host_ip[INET_ADDRSTRLEN] = {0}; + struct in_addr host_addr = {.s_addr = host_be}; + if (!inet_ntop(AF_INET, &host_addr, host_ip, sizeof(host_ip))) { + ret = -errno; + goto out; + } + if (have_old && old.host_ip[0] && strcmp(old.host_ip, host_ip) != 0) { + struct in_addr old_host; + if (inet_pton(AF_INET, old.host_ip, &old_host) == 1) + (void)ds_nl_del_addr4(ctx, shim, old_host.s_addr, 32); + } + ret = ds_nl_add_addr4(ctx, shim, host_be, 32); + if (ret < 0) + goto out; + + uint32_t guest_be = 0; + if (cfg->net_ipam == DS_NET_IPAM_STATIC && cfg->net_address[0]) { + char cidr[sizeof(cfg->net_address)]; + safe_strncpy(cidr, cfg->net_address, sizeof(cidr)); + char *slash = strchr(cidr, '/'); + if (slash) + *slash = '\0'; + (void)inet_pton(AF_INET, cidr, &guest_be); + } else if (discover_guest) { + (void)ha_get_guest_ip(pid, &guest_be); + } + + char guest_ip[INET_ADDRSTRLEN] = {0}; + if (guest_be) { + struct in_addr guest_addr = {.s_addr = guest_be}; + (void)inet_ntop(AF_INET, &guest_addr, guest_ip, sizeof(guest_ip)); + } + if (have_old && old.guest_ip[0] && strcmp(old.guest_ip, guest_ip) != 0) { + struct in_addr old_guest; + if (inet_pton(AF_INET, old.guest_ip, &old_guest) == 1) { + (void)ds_nl_del_route4(ctx, old_guest.s_addr, 32, 0, shim_idx); + (void)ds_nl_del_rule4(ctx, 0, 0, old_guest.s_addr, 32, RT_TABLE_MAIN, + DS_HA_RULE_PRIORITY); + } + } + if (guest_be) { + ret = ds_nl_add_route4(ctx, guest_be, 32, 0, shim_idx); + if (ret == 0) + ret = ds_nl_add_rule4(ctx, 0, 0, guest_be, 32, RT_TABLE_MAIN, + DS_HA_RULE_PRIORITY); + if (ret < 0) + goto out; + } + + struct ha_state state = {0}; + safe_strncpy(state.mode, "shim", sizeof(state.mode)); + safe_strncpy(state.shim, shim, sizeof(state.shim)); + safe_strncpy(state.parent, cfg->net_parent, sizeof(state.parent)); + safe_strncpy(state.host_ip, host_ip, sizeof(state.host_ip)); + safe_strncpy(state.guest_ip, guest_ip, sizeof(state.guest_ip)); + state.pid = pid; + if (!have_old || !ha_state_equal(&old, &state)) + ha_write_state(cfg, &state); + if (!have_old || strcmp(old.guest_ip, guest_ip) != 0) + ds_log("[NET] Host access shim ready: %s host=%s/32 guest=%s", + shim, host_ip, guest_ip[0] ? guest_ip : "waiting-for-DHCP"); +out: + ds_nl_close(ctx); + ha_unlock(lock); + return ret; +} + +int ds_host_access_setup(struct ds_config *cfg, pid_t child_pid) { + if (!cfg || child_pid <= 0 || cfg->host_access == DS_HOST_ACCESS_NONE) + return 0; + char stop_path[PATH_MAX]; + ha_stop_path(cfg, stop_path, sizeof(stop_path)); + unlink(stop_path); + if (cfg->host_access == DS_HOST_ACCESS_PTP) + return ha_setup_ptp(cfg, child_pid); + if (cfg->host_access == DS_HOST_ACCESS_SHIM) + return ha_setup_shim(cfg, child_pid, 0); + return -EINVAL; +} + +void ds_host_access_refresh(struct ds_config *cfg, pid_t child_pid) { + if (!cfg || child_pid <= 0) + return; + if (ha_is_stopping(cfg)) + return; + if (cfg->host_access == DS_HOST_ACCESS_PTP) + (void)ha_setup_ptp(cfg, child_pid); + else if (cfg->host_access == DS_HOST_ACCESS_SHIM) + (void)ha_setup_shim(cfg, child_pid, 1); +} + +static int ha_other_live_shim_users(const char *shim, + const char *skip_path) { + DIR *dir = opendir(get_net_dir()); + if (!dir) + return 0; + int count = 0; + struct dirent *ent; + while ((ent = readdir(dir)) != NULL) { + if (strncmp(ent->d_name, "ha_", 3) != 0 || + !strstr(ent->d_name, ".state")) + continue; + char path[PATH_MAX + NAME_MAX + 2]; + snprintf(path, sizeof(path), "%s/%s", get_net_dir(), ent->d_name); + if (skip_path && strcmp(path, skip_path) == 0) + continue; + struct ha_state state; + if (ha_read_state_path(path, &state) == 0 && + strcmp(state.mode, "shim") == 0 && strcmp(state.shim, shim) == 0 && + state.pid > 0 && (kill(state.pid, 0) == 0 || errno == EPERM)) { + count++; + break; + } + } + closedir(dir); + return count; +} + +void ds_host_access_cleanup(struct ds_config *cfg, pid_t child_pid) { + (void)child_pid; + if (!cfg || cfg->host_access == DS_HOST_ACCESS_NONE) + return; + /* The CLI can begin cleanup while the monitor is completing its final + * heartbeat. Leave a tombstone before taking any segment lock, and check it + * again inside setup after acquiring that lock. The next genuine start + * removes the marker in ds_host_access_setup(). */ + char stop_path[PATH_MAX]; + ha_stop_path(cfg, stop_path, sizeof(stop_path)); + if (write_file_atomic(stop_path, "1\n") < 0) + ds_warn("[NET] Host access: failed to write stop marker: %s", + strerror(errno)); + char path[PATH_MAX]; + ha_state_path(cfg, path, sizeof(path)); + struct ha_state state = {0}; + (void)ha_read_state_path(path, &state); + + if (cfg->host_access == DS_HOST_ACCESS_PTP) { + char host[IFNAMSIZ], peer[IFNAMSIZ]; + ha_ptp_names(cfg, host, peer); + int lock = ha_lock(host); + ds_nl_ctx_t *ctx = ds_nl_open(); + if (ctx) { + ds_nl_del_link(ctx, host); + if (ds_nl_count_ifaces_with_prefix(ctx, "ds-pt") == 0) + (void)ds_nl_del_rule4(ctx, 0, 0, inet_addr(DS_HA_PTP_POOL), + DS_HA_PTP_POOL_PREFIX, RT_TABLE_MAIN, + DS_HA_RULE_PRIORITY); + ds_nl_close(ctx); + } + unlink(path); + ha_unlock(lock); + return; + } + + if (cfg->host_access == DS_HOST_ACCESS_SHIM) { + char shim[IFNAMSIZ]; + if (state.shim[0]) + safe_strncpy(shim, state.shim, sizeof(shim)); + else + ha_shim_name(cfg, shim); + int lock = ha_lock(shim); + ds_nl_ctx_t *ctx = ds_nl_open(); + if (ctx && state.guest_ip[0]) { + struct in_addr guest; + int idx = ds_nl_get_ifindex(ctx, shim); + if (inet_pton(AF_INET, state.guest_ip, &guest) == 1) { + if (idx > 0) + (void)ds_nl_del_route4(ctx, guest.s_addr, 32, 0, idx); + (void)ds_nl_del_rule4(ctx, 0, 0, guest.s_addr, 32, RT_TABLE_MAIN, + DS_HA_RULE_PRIORITY); + } + } + unlink(path); + if (ctx && !ha_other_live_shim_users(shim, path)) { + ds_nl_del_link(ctx, shim); + ds_log("[NET] Host access shim cleanup: removed idle %s", shim); + } + if (ctx) + ds_nl_close(ctx); + ha_unlock(lock); + } +} diff --git a/src/net/netlink.c b/src/net/netlink.c index 12ebf999..74c86bf5 100644 --- a/src/net/netlink.c +++ b/src/net/netlink.c @@ -672,6 +672,96 @@ int ds_nl_add_addr4(ds_nl_ctx_t *ctx, const char *ifname, uint32_t ip_be, return ds_nl_talk(ctx, &req.n); } +int ds_nl_del_addr4(ds_nl_ctx_t *ctx, const char *ifname, uint32_t ip_be, + uint8_t prefix) { + if (prefix > 32) + return -EINVAL; + int idx = ds_nl_get_ifindex(ctx, ifname); + if (idx <= 0) + return 0; + struct { + struct nlmsghdr n; + struct ifaddrmsg ifa; + char buf[128]; + } req; + memset(&req, 0, sizeof(req)); + req.n.nlmsg_len = NLMSG_LENGTH(sizeof(struct ifaddrmsg)); + req.n.nlmsg_type = RTM_DELADDR; + req.n.nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK; + req.ifa.ifa_family = AF_INET; + req.ifa.ifa_prefixlen = prefix; + req.ifa.ifa_index = (unsigned int)idx; + nl_addattr(&req.n, (int)sizeof(req), IFA_LOCAL, &ip_be, 4); + nl_addattr(&req.n, (int)sizeof(req), IFA_ADDRESS, &ip_be, 4); + int ret = ds_nl_talk(ctx, &req.n); + return ret == -EADDRNOTAVAIL ? 0 : ret; +} + +int ds_nl_get_addr4(ds_nl_ctx_t *ctx, const char *ifname, uint32_t *ip_be, + uint8_t *prefix) { + int target = ds_nl_get_ifindex(ctx, ifname); + if (target <= 0) + return -ENODEV; + + struct { + struct nlmsghdr n; + struct ifaddrmsg ifa; + } req; + memset(&req, 0, sizeof(req)); + req.n.nlmsg_len = NLMSG_LENGTH(sizeof(struct ifaddrmsg)); + req.n.nlmsg_type = RTM_GETADDR; + req.n.nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP; + req.ifa.ifa_family = AF_INET; + req.n.nlmsg_seq = ++ctx->seq; + req.n.nlmsg_pid = (uint32_t)ctx->pid; + if (send(ctx->fd, &req, req.n.nlmsg_len, 0) < 0) + return -errno; + + int found = -ENOENT; + uint8_t buf[NL_BUFSIZE]; + for (;;) { + ssize_t n = recv(ctx->fd, buf, sizeof(buf), 0); + if (n < 0) { + if (errno == EINTR) + continue; + return -errno; + } + struct nlmsghdr *h = (struct nlmsghdr *)buf; + for (; NLMSG_OK(h, (uint32_t)n); h = NLMSG_NEXT(h, n)) { + if (h->nlmsg_seq != req.n.nlmsg_seq) + continue; + if (h->nlmsg_type == NLMSG_DONE) + return found; + if (h->nlmsg_type == NLMSG_ERROR) { + struct nlmsgerr *err = NLMSG_DATA(h); + return err->error ? err->error : found; + } + if (h->nlmsg_type != RTM_NEWADDR) + continue; + struct ifaddrmsg *ifa = NLMSG_DATA(h); + if (ifa->ifa_family != AF_INET || (int)ifa->ifa_index != target) + continue; + struct rtattr *rta = IFA_RTA(ifa); + int len = (int)IFA_PAYLOAD(h); + uint32_t candidate = 0; + for (; RTA_OK(rta, len); rta = RTA_NEXT(rta, len)) { + if (rta->rta_type == IFA_LOCAL || + (rta->rta_type == IFA_ADDRESS && candidate == 0)) + candidate = nl_rta_u32(rta); + } + if (candidate != 0) { + if (ip_be) + *ip_be = candidate; + if (prefix) + *prefix = ifa->ifa_prefixlen; + found = 0; + if (!(ifa->ifa_flags & IFA_F_SECONDARY)) + return 0; + } + } + } +} + /* --------------------------------------------------------------------------- * Add an IPv4 route * dst_be=0 + dst_len=0 → default route. @@ -705,6 +795,33 @@ int ds_nl_add_route4(ds_nl_ctx_t *ctx, uint32_t dst_be, uint8_t dst_len, return ds_nl_talk(ctx, &req.n); } +int ds_nl_del_route4(ds_nl_ctx_t *ctx, uint32_t dst_be, uint8_t dst_len, + uint32_t gw_be, int oif_idx) { + struct { + struct nlmsghdr n; + struct rtmsg r; + char buf[256]; + } req; + memset(&req, 0, sizeof(req)); + req.n.nlmsg_len = NLMSG_LENGTH(sizeof(struct rtmsg)); + req.n.nlmsg_type = RTM_DELROUTE; + req.n.nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK; + req.r.rtm_family = AF_INET; + req.r.rtm_dst_len = dst_len; + req.r.rtm_table = RT_TABLE_MAIN; + req.r.rtm_protocol = RTPROT_BOOT; + req.r.rtm_scope = (gw_be == 0) ? RT_SCOPE_LINK : RT_SCOPE_UNIVERSE; + req.r.rtm_type = RTN_UNICAST; + if (dst_len > 0) + nl_addattr(&req.n, (int)sizeof(req), RTA_DST, &dst_be, 4); + if (gw_be) + nl_addattr(&req.n, (int)sizeof(req), RTA_GATEWAY, &gw_be, 4); + if (oif_idx > 0) + nl_addattr(&req.n, (int)sizeof(req), RTA_OIF, &oif_idx, (int)sizeof(int)); + int ret = ds_nl_talk(ctx, &req.n); + return ret == -ESRCH || ret == -ENOENT ? 0 : ret; +} + /* --------------------------------------------------------------------------- * Move an interface into a network namespace (by fd) * ---------------------------------------------------------------------------*/ diff --git a/src/net/network.c b/src/net/network.c index c7c84768..e17530be 100644 --- a/src/net/network.c +++ b/src/net/network.c @@ -2385,6 +2385,12 @@ void ds_net_cleanup(struct ds_config *cfg, pid_t container_pid) { * to us, so it is safe to run for every stopping container. */ ds_net_gateway_teardown(cfg->container_name); + if ((cfg->net_mode == DS_NET_IPVLAN || cfg->net_mode == DS_NET_MACVLAN) && + cfg->host_access != DS_HOST_ACCESS_NONE) { + ds_host_access_cleanup(cfg, container_pid); + return; + } + if (cfg->net_mode == DS_NET_GATEWAY) { ds_nl_ctx_t *ctx = ds_nl_open(); if (!ctx) diff --git a/src/socketd_bridge.c b/src/socketd_bridge.c index 4c34aa48..de791fa5 100644 --- a/src/socketd_bridge.c +++ b/src/socketd_bridge.c @@ -765,6 +765,10 @@ static int socketd_validate_start_config(struct ds_config *cfg) { reason); return -1; } + } else if (cfg->host_access != DS_HOST_ACCESS_NONE) { + ds_error("Container '%s' enables host access outside ipvlan/macvlan mode", + cfg->container_name); + return -1; } return 0; From 2c6632df2e2d84bbb836188b9c0bda0ef9c1510b Mon Sep 17 00:00:00 2001 From: EricMa <307748790@qq.com> Date: Sat, 8 Aug 2026 13:01:40 +0800 Subject: [PATCH 05/10] fix: route primary addresses over PTP host access --- src/net/host_access.c | 97 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 86 insertions(+), 11 deletions(-) diff --git a/src/net/host_access.c b/src/net/host_access.c index 0e1f25aa..4483f5d2 100644 --- a/src/net/host_access.c +++ b/src/net/host_access.c @@ -1,9 +1,10 @@ /* * Direct-L2 host access for ipvlan/macvlan containers. * - * PTP gives every container a private veth /30. SHIM creates one shared - * ipvlan/macvlan child per parent+kind, borrows the parent's IPv4 as /32, and - * installs a host route for every live container address. + * PTP gives every container a private veth /30 and routes the host/container + * primary IPv4 addresses over it. SHIM creates one shared ipvlan/macvlan + * child per parent+kind, borrows the parent's IPv4 as /32, and installs a host + * route for every live container address. */ #include "droidspace.h" #include @@ -259,7 +260,9 @@ void ds_host_access_resolve_ptp(struct ds_config *cfg) { ha_unlock(lock); } -static int ha_configure_netns_ptp(pid_t pid, uint32_t guest_be) { +static int ha_configure_netns_ptp(pid_t pid, uint32_t guest_be, + uint32_t host_be, uint32_t old_host_be, + uint32_t gateway_be) { int self_fd = open("/proc/self/ns/net", O_RDONLY | O_CLOEXEC); if (self_fd < 0) return -errno; @@ -287,6 +290,13 @@ static int ha_configure_netns_ptp(pid_t pid, uint32_t guest_be) { ret = ds_nl_link_up(ctx, DS_HA_GUEST_IF); if (ret == 0) (void)ha_write_disable_ipv6(DS_HA_GUEST_IF); + int guest_idx = ds_nl_get_ifindex(ctx, DS_HA_GUEST_IF); + if (ret == 0 && guest_idx <= 0) + ret = -ENODEV; + if (ret == 0 && old_host_be && old_host_be != host_be) + (void)ds_nl_del_route4(ctx, old_host_be, 32, gateway_be, guest_idx); + if (ret == 0 && host_be) + ret = ds_nl_add_route4(ctx, host_be, 32, gateway_be, guest_idx); ds_nl_close(ctx); restore: if (setns(self_fd, CLONE_NEWNET) < 0 && ret == 0) @@ -328,7 +338,8 @@ static int ha_get_guest_ip(pid_t pid, uint32_t *ip_be) { return ret; } -static int ha_setup_ptp(struct ds_config *cfg, pid_t pid) { +static int ha_setup_ptp(struct ds_config *cfg, pid_t pid, + int discover_guest) { uint32_t network_be; if (ha_parse_ptp_cidr(cfg->host_access_ptp_cidr, &network_be) < 0) return -EINVAL; @@ -378,18 +389,72 @@ static int ha_setup_ptp(struct ds_config *cfg, pid_t pid) { ret = ds_nl_add_rule4(ctx, 0, 0, inet_addr(DS_HA_PTP_POOL), DS_HA_PTP_POOL_PREFIX, RT_TABLE_MAIN, DS_HA_RULE_PRIORITY); + + uint32_t host_main_be = 0; + (void)ds_nl_get_addr4(ctx, cfg->net_parent, &host_main_be, NULL); + uint32_t old_host_main_be = 0; + if (have_old && old.host_ip[0]) + (void)inet_pton(AF_INET, old.host_ip, &old_host_main_be); if (ret == 0) - ret = ha_configure_netns_ptp(pid, guest_be); + ret = ha_configure_netns_ptp(pid, guest_be, host_main_be, + old_host_main_be, host_be); + + uint32_t guest_main_be = 0; + if (cfg->net_ipam == DS_NET_IPAM_STATIC && cfg->net_address[0]) { + char cidr[sizeof(cfg->net_address)]; + safe_strncpy(cidr, cfg->net_address, sizeof(cidr)); + char *slash = strchr(cidr, '/'); + if (slash) + *slash = '\0'; + (void)inet_pton(AF_INET, cidr, &guest_main_be); + } else if (discover_guest) { + (void)ha_get_guest_ip(pid, &guest_main_be); + } + + int host_idx = ds_nl_get_ifindex(ctx, host); + if (ret == 0 && host_idx <= 0) + ret = -ENODEV; + char host_ip[INET_ADDRSTRLEN] = {0}; + char guest_ip[INET_ADDRSTRLEN] = {0}; + if (host_main_be) { + struct in_addr addr = {.s_addr = host_main_be}; + (void)inet_ntop(AF_INET, &addr, host_ip, sizeof(host_ip)); + } + if (guest_main_be) { + struct in_addr addr = {.s_addr = guest_main_be}; + (void)inet_ntop(AF_INET, &addr, guest_ip, sizeof(guest_ip)); + } + if (ret == 0 && have_old && old.guest_ip[0] && + strcmp(old.guest_ip, guest_ip) != 0) { + struct in_addr old_guest; + if (inet_pton(AF_INET, old.guest_ip, &old_guest) == 1) { + (void)ds_nl_del_route4(ctx, old_guest.s_addr, 32, guest_be, host_idx); + (void)ds_nl_del_rule4(ctx, 0, 0, old_guest.s_addr, 32, + RT_TABLE_MAIN, DS_HA_RULE_PRIORITY); + } + } + if (ret == 0 && guest_main_be) { + ret = ds_nl_add_route4(ctx, guest_main_be, 32, guest_be, host_idx); + if (ret == 0) + ret = ds_nl_add_rule4(ctx, 0, 0, guest_main_be, 32, RT_TABLE_MAIN, + DS_HA_RULE_PRIORITY); + } if (ret == 0) { struct ha_state state = {0}; safe_strncpy(state.mode, "ptp", sizeof(state.mode)); safe_strncpy(state.shim, host, sizeof(state.shim)); + safe_strncpy(state.parent, cfg->net_parent, sizeof(state.parent)); + safe_strncpy(state.host_ip, host_ip, sizeof(state.host_ip)); + safe_strncpy(state.guest_ip, guest_ip, sizeof(state.guest_ip)); state.pid = pid; if (!have_old || !ha_state_equal(&old, &state)) ha_write_state(cfg, &state); - if (!have_old || created) - ds_log("[NET] Host access PTP ready: host=%s guest=%s (%s)", host, - DS_HA_GUEST_IF, cfg->host_access_ptp_cidr); + if (!have_old || created || strcmp(old.guest_ip, guest_ip) != 0) + ds_log("[NET] Host access PTP ready: host=%s guest=%s (%s), " + "primary=%s<->%s", + host, DS_HA_GUEST_IF, cfg->host_access_ptp_cidr, + host_ip[0] ? host_ip : "unavailable", + guest_ip[0] ? guest_ip : "waiting-for-DHCP"); } out: if (ret < 0) @@ -514,7 +579,7 @@ int ds_host_access_setup(struct ds_config *cfg, pid_t child_pid) { ha_stop_path(cfg, stop_path, sizeof(stop_path)); unlink(stop_path); if (cfg->host_access == DS_HOST_ACCESS_PTP) - return ha_setup_ptp(cfg, child_pid); + return ha_setup_ptp(cfg, child_pid, 0); if (cfg->host_access == DS_HOST_ACCESS_SHIM) return ha_setup_shim(cfg, child_pid, 0); return -EINVAL; @@ -526,7 +591,7 @@ void ds_host_access_refresh(struct ds_config *cfg, pid_t child_pid) { if (ha_is_stopping(cfg)) return; if (cfg->host_access == DS_HOST_ACCESS_PTP) - (void)ha_setup_ptp(cfg, child_pid); + (void)ha_setup_ptp(cfg, child_pid, 1); else if (cfg->host_access == DS_HOST_ACCESS_SHIM) (void)ha_setup_shim(cfg, child_pid, 1); } @@ -582,6 +647,16 @@ void ds_host_access_cleanup(struct ds_config *cfg, pid_t child_pid) { int lock = ha_lock(host); ds_nl_ctx_t *ctx = ds_nl_open(); if (ctx) { + if (state.guest_ip[0]) { + struct in_addr guest; + int idx = ds_nl_get_ifindex(ctx, host); + if (inet_pton(AF_INET, state.guest_ip, &guest) == 1) { + if (idx > 0) + (void)ds_nl_del_route4(ctx, guest.s_addr, 32, 0, idx); + (void)ds_nl_del_rule4(ctx, 0, 0, guest.s_addr, 32, + RT_TABLE_MAIN, DS_HA_RULE_PRIORITY); + } + } ds_nl_del_link(ctx, host); if (ds_nl_count_ifaces_with_prefix(ctx, "ds-pt") == 0) (void)ds_nl_del_rule4(ctx, 0, 0, inet_addr(DS_HA_PTP_POOL), From 8bdeafb1134c169f37bd2829b5926c94b4d2469e Mon Sep 17 00:00:00 2001 From: EricMa <307748790@qq.com> Date: Sat, 8 Aug 2026 13:41:52 +0800 Subject: [PATCH 06/10] network: gate direct L2 modes by kernel support --- .../app/ui/component/ContainerConfigForm.kt | 14 +++++- .../app/ui/component/DsDropdown.kt | 8 +++- .../app/ui/screen/RequirementsScreen.kt | 8 ++++ .../droidspaces/app/util/ContainerManager.kt | 23 ++++++++++ Documentation/Kernel-Configuration.md | 10 +++++ Documentation/zh-CN/Kernel-Configuration.md | 10 +++++ src/check.c | 43 +++++++++++++++++++ src/include/droidspace.h | 1 + src/main.c | 3 +- 9 files changed, 116 insertions(+), 4 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt index 012e9778..44d14cb3 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt @@ -126,6 +126,11 @@ fun ContainerConfigForm( var showHwAccessDialog by remember { mutableStateOf(false) } var parentInterfaceMenuExpanded by remember { mutableStateOf(false) } var availableParentInterfaces by remember { mutableStateOf>(emptyList()) } + var directL2Capabilities by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + directL2Capabilities = ContainerManager.getDirectL2Capabilities() + } LaunchedEffect(state.netMode) { if (state.netMode == "ipvlan" || state.netMode == "macvlan") { @@ -284,7 +289,14 @@ fun ContainerConfigForm( clearFocus() onStateChange(state.copy(netMode = mode, disableIPv6 = if (mode == "nat" || mode == "none") true else state.disableIPv6)) }, - leadingIcon = Icons.Default.Public + leadingIcon = Icons.Default.Public, + isOptionEnabled = { mode -> + when (mode) { + "ipvlan" -> directL2Capabilities?.ipvlanSupported == true + "macvlan" -> directL2Capabilities?.macvlanSupported == true + else -> true + } + } ) GatewaySettingsSection( diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/DsDropdown.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/DsDropdown.kt index bde05567..ec8ac9d4 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/DsDropdown.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/DsDropdown.kt @@ -30,7 +30,8 @@ fun DsDropdown( leadingIcon: ImageVector? = null, isError: Boolean = false, supportingText: String? = null, - enabled: Boolean = true + enabled: Boolean = true, + isOptionEnabled: (T) -> Boolean = { true } ) { var expanded by remember { mutableStateOf(false) } val focusManager = LocalFocusManager.current @@ -95,8 +96,10 @@ fun DsDropdown( ) ) { options.forEach { option -> + val optionEnabled = isOptionEnabled(option) DropdownMenuItem( text = { Text(displayName(option), fontWeight = FontWeight.Medium) }, + enabled = optionEnabled, onClick = { onSelect(option) expanded = false @@ -108,7 +111,8 @@ fun DsDropdown( Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.primary + tint = if (optionEnabled) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) ) } } else null diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/RequirementsScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/RequirementsScreen.kt index bfd619c9..62c180f3 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/RequirementsScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/RequirementsScreen.kt @@ -188,6 +188,10 @@ CONFIG_IP_NF_NAT=y # Disable this on older kernels to make internet work CONFIG_ANDROID_PARANOID_NETWORK=n +# Optional direct L2 network modes +CONFIG_IPVLAN=y +CONFIG_MACVLAN=y + # Fix for docker unsafe procfs error CONFIG_USER_NS=y""", guideUrl = "https://github.com/ravindu644/Droidspaces-OSS/blob/main/Documentation/Kernel-Configuration.md#non-gki", @@ -222,6 +226,10 @@ CONFIG_NETFILTER_XT_MATCH_ADDRTYPE=y # Fix for docker unsafe procfs error CONFIG_USER_NS=y +# Direct L2 network modes +CONFIG_IPVLAN=y +CONFIG_MACVLAN=y + # UFW support CONFIG_NETFILTER_XT_TARGET_REJECT=y CONFIG_NETFILTER_XT_TARGET_LOG=y diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt index e732c5e0..a57b935e 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt @@ -25,6 +25,11 @@ data class PortForward( val proto: String = "tcp" ) +data class DirectL2Capabilities( + val ipvlanSupported: Boolean = false, + val macvlanSupported: Boolean = false +) + data class ContainerInfo( val name: String, val hostname: String, @@ -477,6 +482,24 @@ object ContainerManager { } } + /** Query live kernel support using the backend's temporary Netlink probes. */ + suspend fun getDirectL2Capabilities(): DirectL2Capabilities = withContext(Dispatchers.IO) { + try { + val result = Shell.cmd("\"${Constants.DROIDSPACES_BINARY_PATH}\" --format check 2>/dev/null").exec() + if (!result.isSuccess) return@withContext DirectL2Capabilities() + val values = result.out.mapNotNull { line -> + val parts = line.trim().split("=", limit = 2) + if (parts.size == 2) parts[0] to parts[1] else null + }.toMap() + DirectL2Capabilities( + ipvlanSupported = values["CONFIG_IPVLAN"] == "1", + macvlanSupported = values["CONFIG_MACVLAN"] == "1" + ) + } catch (_: Exception) { + DirectL2Capabilities() + } + } + /** * Update container configuration. * Only updates the configurable options (hostname, flags), not name or rootfsPath. diff --git a/Documentation/Kernel-Configuration.md b/Documentation/Kernel-Configuration.md index dbe7be14..11793012 100644 --- a/Documentation/Kernel-Configuration.md +++ b/Documentation/Kernel-Configuration.md @@ -121,6 +121,10 @@ CONFIG_IP_NF_NAT=y # Disable this on older kernels to make internet work CONFIG_ANDROID_PARANOID_NETWORK=n +# Optional direct L2 network modes +CONFIG_IPVLAN=y +CONFIG_MACVLAN=y + # Fix for docker unsafe procfs error CONFIG_USER_NS=y ``` @@ -250,6 +254,10 @@ CONFIG_NETFILTER_XT_MATCH_ADDRTYPE=y # Fix for docker unsafe procfs error CONFIG_USER_NS=y +# Direct L2 network modes +CONFIG_IPVLAN=y +CONFIG_MACVLAN=y + # UFW support CONFIG_NETFILTER_XT_TARGET_REJECT=y CONFIG_NETFILTER_XT_TARGET_LOG=y @@ -308,6 +316,7 @@ This checks for: - devtmpfs support - OverlayFS support (optional, for volatile mode) - VETH and Bridge support (optional, for NAT mode) +- IPVLAN and MACVLAN support (optional, for direct L2 modes) - PTY/devpts support - Loop device support - ext4 support @@ -333,6 +342,7 @@ This checks for: | OverlayFS | `CONFIG_OVERLAY_FS` | Volatile mode unavailable. | | Network namespace | `CONFIG_NET_NS=y` | NAT and None modes unavailable. | | VETH / Bridge | `CONFIG_VETH` / `CONFIG_BRIDGE` | NAT mode unavailable. | +| IPVLAN / MACVLAN | `CONFIG_IPVLAN` / `CONFIG_MACVLAN` | The corresponding direct L2 mode is unavailable. | | Seccomp | `CONFIG_SECCOMP=y` | Seccomp shield disabled. Security risk. | --- diff --git a/Documentation/zh-CN/Kernel-Configuration.md b/Documentation/zh-CN/Kernel-Configuration.md index c24f6a36..4e097d4b 100644 --- a/Documentation/zh-CN/Kernel-Configuration.md +++ b/Documentation/zh-CN/Kernel-Configuration.md @@ -121,6 +121,10 @@ CONFIG_IP_NF_NAT=y # 在旧内核上禁用此选项以使互联网正常工作 CONFIG_ANDROID_PARANOID_NETWORK=n + +# 可选的直连二层网络模式 +CONFIG_IPVLAN=y +CONFIG_MACVLAN=y ``` @@ -246,6 +250,10 @@ CONFIG_NETFILTER_XT_MATCH_ADDRTYPE=y # --- 以下配置为可选但建议开启 --- +# 直连二层网络模式 +CONFIG_IPVLAN=y +CONFIG_MACVLAN=y + # UFW 支持 CONFIG_NETFILTER_XT_TARGET_REJECT=y CONFIG_NETFILTER_XT_TARGET_LOG=y @@ -304,6 +312,7 @@ su -c droidspaces check - devtmpfs 支持 - OverlayFS 支持(可选,用于易失模式) - VETH 和 Bridge 支持(可选,用于 NAT 模式) +- IPVLAN 和 MACVLAN 支持(可选,用于直连二层模式) - PTY/devpts 支持 - Loop 设备支持 - ext4 支持 @@ -329,6 +338,7 @@ su -c droidspaces check | OverlayFS | `CONFIG_OVERLAY_FS` | 易失模式不可用。 | | 网络命名空间 | `CONFIG_NET_NS=y` | NAT 和 None 模式不可用。 | | VETH / Bridge | `CONFIG_VETH` / `CONFIG_BRIDGE` | NAT 模式不可用。 | +| IPVLAN / MACVLAN | `CONFIG_IPVLAN` / `CONFIG_MACVLAN` | 对应的直连二层模式不可用。 | | Seccomp | `CONFIG_SECCOMP=y` | Seccomp 防护盾已禁用。存在安全风险。 | --- diff --git a/src/check.c b/src/check.c index acd3f141..5f677914 100644 --- a/src/check.c +++ b/src/check.c @@ -145,6 +145,34 @@ static int check_veth_support(void) { return (ret == 0); } +/* Probe direct-L2 link types against real host interfaces. A live NEWLINK + * roundtrip detects both built-in (=y) and loadable (=m) implementations and + * also catches kernels where the feature exists but cannot actually be used + * by the runtime. */ +static int check_direct_l2_support(const char *kind) { + if (!is_root) + return 0; + + char ifaces[64][IFNAMSIZ]; + ds_nl_ctx_t *ctx = ds_nl_open(); + if (!ctx) + return 0; + int count = ds_nl_list_ifaces(ctx, ifaces, 64); + ds_nl_close(ctx); + if (count < 0) + return 0; + + for (int i = 0; i < count; i++) { + if (strcmp(ifaces[i], "lo") == 0 || strncmp(ifaces[i], "ds-", 3) == 0) + continue; + char reason[256]; + if (ds_nl_probe_parent_capability(ifaces[i], kind, reason, + sizeof(reason)) == 0) + return 1; + } + return 0; +} + static int check_kernel_version_supported(void) { int major = 0, minor = 0; if (get_kernel_version(&major, &minor) < 0) @@ -400,6 +428,12 @@ int check_requirements_detailed(void) { print_ds_check("Veth pair support", "Required for --net=nat; no fallback exists if absent", check_veth_support(), "OPT"); + print_ds_check("IPvlan support", + "CONFIG_IPVLAN; required for --net=ipvlan", + check_direct_l2_support("ipvlan"), "OPT"); + print_ds_check("Macvlan support", + "CONFIG_MACVLAN; required for --net=macvlan", + check_direct_l2_support("macvlan"), "OPT"); print_ds_check("User namespace", "CONFIG_USER_NS; enable per container with --allow-userns. " "Needed by Docker on some kernels, by sandboxed apps " @@ -428,3 +462,12 @@ int check_requirements_detailed(void) { return 0; } + +int check_requirements_format(void) { + check_root(); + printf("CONFIG_USER_NS=%d\n", + access("/proc/self/ns/user", F_OK) == 0 ? 1 : 0); + printf("CONFIG_IPVLAN=%d\n", check_direct_l2_support("ipvlan")); + printf("CONFIG_MACVLAN=%d\n", check_direct_l2_support("macvlan")); + return 0; +} diff --git a/src/include/droidspace.h b/src/include/droidspace.h index 8749834c..3ef5206e 100644 --- a/src/include/droidspace.h +++ b/src/include/droidspace.h @@ -977,6 +977,7 @@ int is_dangerous_node(const char *name); int check_requirements(void); int check_requirements_hw(int hw_access); int check_requirements_detailed(void); +int check_requirements_format(void); /* --------------------------------------------------------------------------- * daemon.c - daemon, client, and probe entry points diff --git a/src/main.c b/src/main.c index 127a4da6..433544bd 100644 --- a/src/main.c +++ b/src/main.c @@ -1220,7 +1220,8 @@ int main(int argc, char **argv) { /* Basic info commands */ if (strcmp(cmd, "check") == 0) { - ret = check_requirements_detailed(); + ret = cfg.format_output ? check_requirements_format() + : check_requirements_detailed(); goto cleanup; } if (strcmp(cmd, "version") == 0) { From 4d1585488a9a2ad2a4c6ba9e6b4bd081c4d58995 Mon Sep 17 00:00:00 2001 From: EricMa <307748790@qq.com> Date: Sat, 8 Aug 2026 14:20:50 +0800 Subject: [PATCH 07/10] network: warn and enable 4addr for Wi-Fi macvlan --- .../app/ui/component/ContainerConfigForm.kt | 19 ++++++++++ .../src/main/res/values-zh-rCN/strings.xml | 1 + Android/app/src/main/res/values/strings.xml | 1 + src/net/network.c | 38 +++++++++++++++++++ 4 files changed, 59 insertions(+) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt index 44d14cb3..8b991bef 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt @@ -386,6 +386,25 @@ fun ContainerConfigForm( } } } + if (state.netMode == "macvlan" && state.netParent.startsWith("wlan", ignoreCase = true)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(18.dp) + ) + Text( + text = context.getString(R.string.macvlan_wifi_4addr_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary + ) + } + } if (state.netMode == "macvlan") { val macError = !state.isNetMacValid() OutlinedTextField( diff --git a/Android/app/src/main/res/values-zh-rCN/strings.xml b/Android/app/src/main/res/values-zh-rCN/strings.xml index c19000e4..f6f7850c 100644 --- a/Android/app/src/main/res/values-zh-rCN/strings.xml +++ b/Android/app/src/main/res/values-zh-rCN/strings.xml @@ -486,6 +486,7 @@ 自动检测(活动上行) 留空时自动检测 Android 当前活动上行,也可明确指定父接口。 请输入少于 16 个字符的 Linux 接口名。 + 通过 Wi-Fi 使用 Macvlan 时,无线 AP 和手机 Wi-Fi 驱动/固件都必须支持并启用四地址(WDS)模式。Droidspaces 会尝试在所选手机接口上开启 4addr,但无法替你开启 AP 端。 设备 MAC(可选) 留空则由内核选择;填写单播 MAC 可配合路由器固定租约。 请输入有效的单播 MAC,例如 02:11:22:33:44:55。 diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml index dc6783ff..aebe6c53 100644 --- a/Android/app/src/main/res/values/strings.xml +++ b/Android/app/src/main/res/values/strings.xml @@ -247,6 +247,7 @@ Automatic (active uplink) Leave blank to auto-detect the active Android uplink, or enter a parent interface explicitly. Enter a Linux interface name shorter than 16 characters. + Macvlan over Wi-Fi requires both the access point and this phone’s Wi-Fi driver/firmware to support and enable 4-address (WDS) mode. Droidspaces will attempt to enable 4addr on the selected phone interface, but cannot enable it on the access point. Device MAC (optional) Leave blank to let the kernel choose; enter a unicast MAC to keep a router reservation. Enter a valid unicast MAC such as 02:11:22:33:44:55. diff --git a/src/net/network.c b/src/net/network.c index e17530be..c5a1a22d 100644 --- a/src/net/network.c +++ b/src/net/network.c @@ -56,6 +56,23 @@ static void veth_peer_name(const struct ds_config *cfg, pid_t pid, char *buf, snprintf(buf, sz, "%s%d", p, (int)pid); } +/* Android devices which expose nl80211 normally ship iw in /system/bin. + * Keep this optional: macvlan still works on Ethernet, while Wi-Fi also + * depends on driver/firmware and AP-side four-address/WDS support. */ +static int try_enable_wifi_4addr(char *ifname) { + static char *const iw_paths[] = { + "/system/bin/iw", "/vendor/bin/iw", "/usr/sbin/iw", "/usr/bin/iw"}; + + for (size_t i = 0; i < sizeof(iw_paths) / sizeof(iw_paths[0]); i++) { + if (access(iw_paths[i], X_OK) != 0) + continue; + char *const argv[] = {iw_paths[i], "dev", ifname, "set", + "4addr", "on", NULL}; + return run_command_log(argv); + } + return 127; +} + /* Derive a deterministic IP from a PID (avoids sequential collisions) */ static void veth_peer_ip(pid_t pid, char *buf, size_t sz) { /* Multiplicative hash to spread sequential PIDs across the /16 subnet. @@ -936,6 +953,27 @@ int setup_parent_link_host_side(struct ds_config *cfg, pid_t child_pid) { char peer[IFNAMSIZ]; veth_peer_name(cfg, child_pid, peer, sizeof(peer)); + /* A Wi-Fi STA normally transports only its own source MAC. macvlan needs + * 802.11 four-address/WDS operation so frames carrying the guest MAC can + * cross the wireless link. This is best-effort: both the phone driver and + * AP must support/enable it, and some Android drivers reject the request. + * ipvlan shares the parent MAC and does not need this. */ + if (cfg->net_mode == DS_NET_MACVLAN && + strncmp(cfg->net_parent, "wlan", 4) == 0) { + int four_addr = try_enable_wifi_4addr(cfg->net_parent); + if (four_addr == 0) + ds_log("[NET] Enabled 4addr/WDS on Wi-Fi parent %s", + cfg->net_parent); + else if (four_addr == 127) + ds_warn("[NET] Could not enable 4addr/WDS on Wi-Fi parent %s: iw " + "is unavailable; macvlan traffic requires phone and AP support", + cfg->net_parent); + else + ds_warn("[NET] Could not enable 4addr/WDS on Wi-Fi parent %s (iw exit " + "%d); macvlan traffic requires phone and AP support", + cfg->net_parent, four_addr); + } + ds_nl_ctx_t *ctx = ds_nl_open(); if (!ctx) return -errno; From 9074b2b8175d1817786751e90367e7fe2c8fe0bf Mon Sep 17 00:00:00 2001 From: EricMa <307748790@qq.com> Date: Sat, 8 Aug 2026 14:29:52 +0800 Subject: [PATCH 08/10] ui: always show macvlan Wi-Fi requirements --- .../com/droidspaces/app/ui/component/ContainerConfigForm.kt | 2 +- Android/app/src/main/res/values-zh-rCN/strings.xml | 2 +- Android/app/src/main/res/values/strings.xml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt index 8b991bef..e1462a7a 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt @@ -386,7 +386,7 @@ fun ContainerConfigForm( } } } - if (state.netMode == "macvlan" && state.netParent.startsWith("wlan", ignoreCase = true)) { + if (state.netMode == "macvlan") { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), diff --git a/Android/app/src/main/res/values-zh-rCN/strings.xml b/Android/app/src/main/res/values-zh-rCN/strings.xml index f6f7850c..dc75541c 100644 --- a/Android/app/src/main/res/values-zh-rCN/strings.xml +++ b/Android/app/src/main/res/values-zh-rCN/strings.xml @@ -486,7 +486,7 @@ 自动检测(活动上行) 留空时自动检测 Android 当前活动上行,也可明确指定父接口。 请输入少于 16 个字符的 Linux 接口名。 - 通过 Wi-Fi 使用 Macvlan 时,无线 AP 和手机 Wi-Fi 驱动/固件都必须支持并启用四地址(WDS)模式。Droidspaces 会尝试在所选手机接口上开启 4addr,但无法替你开启 AP 端。 + Macvlan 通过 Wi-Fi 使用时,依赖无线路由器/AP 和手机两端都支持且已启用 4addr(WDS)模式。Droidspaces 会尝试在手机端开启 4addr,但无法替你开启路由器端。 设备 MAC(可选) 留空则由内核选择;填写单播 MAC 可配合路由器固定租约。 请输入有效的单播 MAC,例如 02:11:22:33:44:55。 diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml index aebe6c53..d6c34798 100644 --- a/Android/app/src/main/res/values/strings.xml +++ b/Android/app/src/main/res/values/strings.xml @@ -247,7 +247,7 @@ Automatic (active uplink) Leave blank to auto-detect the active Android uplink, or enter a parent interface explicitly. Enter a Linux interface name shorter than 16 characters. - Macvlan over Wi-Fi requires both the access point and this phone’s Wi-Fi driver/firmware to support and enable 4-address (WDS) mode. Droidspaces will attempt to enable 4addr on the selected phone interface, but cannot enable it on the access point. + Macvlan over Wi-Fi depends on both the wireless router/AP and this phone supporting and enabling 4addr (WDS) mode. Droidspaces will try to enable 4addr on the phone, but cannot enable it on the router. Device MAC (optional) Leave blank to let the kernel choose; enter a unicast MAC to keep a router reservation. Enter a valid unicast MAC such as 02:11:22:33:44:55. From 42d79f352240dc58a63e18cbcb4949ee04f9154f Mon Sep 17 00:00:00 2001 From: EricMa <307748790@qq.com> Date: Sat, 8 Aug 2026 15:02:54 +0800 Subject: [PATCH 09/10] build: preserve LF endings for shell scripts --- .gitattributes | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..aee91b87 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Shell scripts are executed directly on Android/Linux. CRLF would append a +# carriage return to the shebang interpreter path and make the scripts fail +# with "No such file or directory" when an APK is built from Windows. +*.sh text eol=lf From f58c5620b66b5ecdc0d06ec21ed768c5ae937084 Mon Sep 17 00:00:00 2001 From: EricMa <307748790@qq.com> Date: Sat, 8 Aug 2026 15:39:30 +0800 Subject: [PATCH 10/10] build: track generated header dependencies --- Makefile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Makefile b/Makefile index 98e4bcc1..32a99695 100644 --- a/Makefile +++ b/Makefile @@ -65,6 +65,7 @@ SRCS = $(SRC_DIR)/main.c \ # Compiler flags - hardened warning set, all warnings are errors CFLAGS = -Wall -Wextra -Wpedantic -Werror -O2 -flto=auto -std=gnu99 -I$(SRC_DIR)/include -no-pie -pthread +CFLAGS += -MMD -MP CFLAGS += -Wformat=2 -Wformat-security -Wformat-overflow=2 -Wformat-truncation=2 CFLAGS += -Wnull-dereference -Wcast-qual -Wlogical-op -Wshadow -Wdouble-promotion -Wundef CFLAGS += -Wduplicated-cond -Wduplicated-branches -Wimplicit-fallthrough=3 @@ -85,6 +86,7 @@ ARCH := $(shell $(CC) -dumpmachine 2>/dev/null | cut -d'-' -f1 | \ # Per-arch object directory - prevents collisions when building multiple archs OBJ_DIR = $(OUT_DIR)/.obj/$(ARCH) OBJS = $(SRCS:$(SRC_DIR)/%.c=$(OBJ_DIR)/%.o) +DEPS = $(OBJS:.o=.d) # Cross-compiler helper HOME_VAR := $(shell echo $$HOME) @@ -143,6 +145,10 @@ $(OBJ_DIR)/%.o: $(SRC_DIR)/%.c | $(OBJ_DIR) $(Q)mkdir -p $(dir $@) $(Q)$(CC) $(CFLAGS) -c $< -o $@ +# Rebuild every translation unit whose included project header changed. This +# prevents stale objects from silently using an older struct layout. +-include $(DEPS) + # Link step $(BINARY_NAME): $(OBJS) | $(OUT_DIR) $(msg_ld)