diff --git a/build/image-recipe/build.sh b/build/image-recipe/build.sh index bde6fd360..e35b21b9a 100755 --- a/build/image-recipe/build.sh +++ b/build/image-recipe/build.sh @@ -1,7 +1,6 @@ #!/bin/bash set -e -MAX_IMG_LEN=$((4 * 1024 * 1024 * 1024)) # 4GB echo "==== StartOS Image Build ====" @@ -332,10 +331,10 @@ fi if [ "${IB_TARGET_PLATFORM}" = "raspberrypi" ]; then ln -sf /usr/bin/pi-beep /usr/local/bin/beep - sh /boot/config.sh > /boot/config.txt + sh /boot/firmware/config.sh > /boot/firmware/config.txt mkinitramfs -c gzip -o /boot/initrd.img-${RPI_KERNEL_VERSION}-rpi-v8 ${RPI_KERNEL_VERSION}-rpi-v8 mkinitramfs -c gzip -o /boot/initrd.img-${RPI_KERNEL_VERSION}-rpi-2712 ${RPI_KERNEL_VERSION}-rpi-2712 - cp /usr/lib/u-boot/rpi_arm64/u-boot.bin /boot/u-boot.bin + cp /usr/lib/u-boot/rpi_arm64/u-boot.bin /boot/firmware/u-boot.bin fi useradd --shell /bin/bash -G startos -m start9 @@ -411,7 +410,16 @@ elif [ "${IMAGE_TYPE}" = img ]; then BOOT_LEN=$((2 * 1024 * 1024 * 1024)) BOOT_END=$((BOOT_START + BOOT_LEN - 1)) ROOT_START=$((BOOT_END + 1)) - ROOT_LEN=$((MAX_IMG_LEN - ROOT_START)) + + # Size root partition to fit the squashfs + 256MB overhead for btrfs + # metadata and config overlay, avoiding the need for btrfs resize + SQUASHFS_SIZE=$(stat -c %s $prep_results_dir/binary/live/filesystem.squashfs) + ROOT_LEN=$(( SQUASHFS_SIZE + 256 * 1024 * 1024 )) + # Align to sector boundary + ROOT_LEN=$(( (ROOT_LEN + SECTOR_LEN - 1) / SECTOR_LEN * SECTOR_LEN )) + + # Total image: partitions + GPT backup header (34 sectors) + IMG_LEN=$((ROOT_START + ROOT_LEN + 34 * SECTOR_LEN)) # Fixed GPT partition UUIDs (deterministic, based on old MBR disk ID cb15ae4d) FW_UUID=cb15ae4d-0001-4000-8000-000000000001 @@ -420,7 +428,7 @@ elif [ "${IMAGE_TYPE}" = img ]; then ROOT_UUID=cb15ae4d-0004-4000-8000-000000000004 TARGET_NAME=$prep_results_dir/${IMAGE_BASENAME}.img - truncate -s $MAX_IMG_LEN $TARGET_NAME + truncate -s $IMG_LEN $TARGET_NAME sfdisk $TARGET_NAME <<-EOF label: gpt @@ -431,10 +439,23 @@ elif [ "${IMAGE_TYPE}" = img ]; then ${TARGET_NAME}4 : start=$((ROOT_START / SECTOR_LEN)), size=$((ROOT_LEN / SECTOR_LEN)), type=B921B045-1DF0-41C3-AF44-4C6F280D3FAE, uuid=${ROOT_UUID}, name="root" EOF - FW_DEV=$(losetup --show -f --offset $FW_START --sizelimit $FW_LEN $TARGET_NAME) - ESP_DEV=$(losetup --show -f --offset $ESP_START --sizelimit $ESP_LEN $TARGET_NAME) - BOOT_DEV=$(losetup --show -f --offset $BOOT_START --sizelimit $BOOT_LEN $TARGET_NAME) - ROOT_DEV=$(losetup --show -f --offset $ROOT_START --sizelimit $ROOT_LEN $TARGET_NAME) + # Create named loop device nodes (high minor numbers to avoid conflicts) + # and detach any stale ones from previous failed builds + FW_DEV=/dev/startos-loop-fw + ESP_DEV=/dev/startos-loop-esp + BOOT_DEV=/dev/startos-loop-boot + ROOT_DEV=/dev/startos-loop-root + for dev in $FW_DEV:200 $ESP_DEV:201 $BOOT_DEV:202 $ROOT_DEV:203; do + name=${dev%:*} + minor=${dev#*:} + [ -e $name ] || mknod $name b 7 $minor + losetup -d $name 2>/dev/null || true + done + + losetup $FW_DEV --offset $FW_START --sizelimit $FW_LEN $TARGET_NAME + losetup $ESP_DEV --offset $ESP_START --sizelimit $ESP_LEN $TARGET_NAME + losetup $BOOT_DEV --offset $BOOT_START --sizelimit $BOOT_LEN $TARGET_NAME + losetup $ROOT_DEV --offset $ROOT_START --sizelimit $ROOT_LEN $TARGET_NAME mkfs.vfat -F32 -n firmware $FW_DEV mkfs.vfat -F32 -n efi $ESP_DEV @@ -447,18 +468,16 @@ elif [ "${IMAGE_TYPE}" = img ]; then BOOT_STAGING=$(mktemp -d) unsquashfs -n -f -d $BOOT_STAGING $prep_results_dir/binary/live/filesystem.squashfs boot - # Mount partitions - mkdir -p $TMPDIR/firmware $TMPDIR/efi $TMPDIR/boot $TMPDIR/root - mount $FW_DEV $TMPDIR/firmware - mount $ESP_DEV $TMPDIR/efi + # Mount partitions (nested: firmware and efi inside boot) + mkdir -p $TMPDIR/boot $TMPDIR/root mount $BOOT_DEV $TMPDIR/boot + mkdir -p $TMPDIR/boot/firmware $TMPDIR/boot/efi + mount $FW_DEV $TMPDIR/boot/firmware + mount $ESP_DEV $TMPDIR/boot/efi mount $ROOT_DEV $TMPDIR/root - # Split boot files: firmware to Part 1, kernels/initramfs to Part 3 (/boot) - cp -a $BOOT_STAGING/boot/. $TMPDIR/firmware/ - for f in $TMPDIR/firmware/vmlinuz-* $TMPDIR/firmware/initrd.img-* $TMPDIR/firmware/System.map-* $TMPDIR/firmware/config-*; do - [ -e "$f" ] && mv "$f" $TMPDIR/boot/ - done + # Copy boot files — nested mounts route firmware/* to the firmware partition + cp -a $BOOT_STAGING/boot/. $TMPDIR/boot/ rm -rf $BOOT_STAGING mkdir $TMPDIR/root/images $TMPDIR/root/config @@ -475,11 +494,9 @@ elif [ "${IMAGE_TYPE}" = img ]; then rsync -a $SOURCE_DIR/raspberrypi/img/ $TMPDIR/next/ # Install GRUB: ESP at /boot/efi (Part 2), /boot (Part 3) - mkdir -p $TMPDIR/next/boot $TMPDIR/next/boot/efi $TMPDIR/next/boot/firmware \ + mkdir -p $TMPDIR/next/boot \ $TMPDIR/next/dev $TMPDIR/next/proc $TMPDIR/next/sys $TMPDIR/next/media/startos/root - mount --bind $TMPDIR/boot $TMPDIR/next/boot - mount --bind $TMPDIR/efi $TMPDIR/next/boot/efi - mount --bind $TMPDIR/firmware $TMPDIR/next/boot/firmware + mount --rbind $TMPDIR/boot $TMPDIR/next/boot mount --bind /dev $TMPDIR/next/dev mount --bind /proc $TMPDIR/next/proc mount --bind /sys $TMPDIR/next/sys @@ -492,9 +509,7 @@ elif [ "${IMAGE_TYPE}" = img ]; then umount $TMPDIR/next/sys umount $TMPDIR/next/proc umount $TMPDIR/next/dev - umount $TMPDIR/next/boot/firmware - umount $TMPDIR/next/boot/efi - umount $TMPDIR/next/boot + umount -l $TMPDIR/next/boot # Fix root= in grub.cfg: update-grub sees loop devices, but the # real device uses a fixed GPT PARTUUID for root (Part 4). @@ -507,39 +522,16 @@ elif [ "${IMAGE_TYPE}" = img ]; then umount $TMPDIR/next umount $TMPDIR/lower - umount $TMPDIR/firmware - umount $TMPDIR/efi + umount $TMPDIR/boot/firmware + umount $TMPDIR/boot/efi umount $TMPDIR/boot umount $TMPDIR/root - # Shrink btrfs to minimum size - SHRINK_MNT=$(mktemp -d) - mount $ROOT_DEV $SHRINK_MNT - btrfs filesystem resize min $SHRINK_MNT - umount $SHRINK_MNT - rmdir $SHRINK_MNT - ROOT_LEN=$(btrfs inspect-internal dump-super $ROOT_DEV | awk '/^total_bytes/ {print $2}') - losetup -d $ROOT_DEV losetup -d $BOOT_DEV losetup -d $ESP_DEV losetup -d $FW_DEV - # Recreate partition table with shrunk root - sfdisk $TARGET_NAME <<-EOF - label: gpt - - ${TARGET_NAME}1 : start=$((FW_START / SECTOR_LEN)), size=$((FW_LEN / SECTOR_LEN)), type=EBD0A0A2-B9E5-4433-87C0-68B6B72699C7, uuid=${FW_UUID}, name="firmware" - ${TARGET_NAME}2 : start=$((ESP_START / SECTOR_LEN)), size=$((ESP_LEN / SECTOR_LEN)), type=C12A7328-F81F-11D2-BA4B-00A0C93EC93B, uuid=${ESP_UUID}, name="efi" - ${TARGET_NAME}3 : start=$((BOOT_START / SECTOR_LEN)), size=$((BOOT_LEN / SECTOR_LEN)), type=0FC63DAF-8483-4772-8E79-3D69D8477DE4, uuid=${BOOT_UUID}, name="boot" - ${TARGET_NAME}4 : start=$((ROOT_START / SECTOR_LEN)), size=$((ROOT_LEN / SECTOR_LEN)), type=B921B045-1DF0-41C3-AF44-4C6F280D3FAE, uuid=${ROOT_UUID}, name="root" - EOF - - TARGET_SIZE=$((ROOT_START + ROOT_LEN)) - truncate -s $TARGET_SIZE $TARGET_NAME - # Move backup GPT to new end of disk after truncation - sgdisk -e $TARGET_NAME - mv $TARGET_NAME $RESULTS_DIR/$IMAGE_BASENAME.img fi diff --git a/build/image-recipe/raspberrypi/squashfs/boot/config.sh b/build/image-recipe/raspberrypi/squashfs/boot/firmware/config.sh similarity index 100% rename from build/image-recipe/raspberrypi/squashfs/boot/config.sh rename to build/image-recipe/raspberrypi/squashfs/boot/firmware/config.sh diff --git a/build/image-recipe/raspberrypi/squashfs/boot/config.txt b/build/image-recipe/raspberrypi/squashfs/boot/firmware/config.txt similarity index 100% rename from build/image-recipe/raspberrypi/squashfs/boot/config.txt rename to build/image-recipe/raspberrypi/squashfs/boot/firmware/config.txt diff --git a/build/lib/scripts/chroot-and-upgrade b/build/lib/scripts/chroot-and-upgrade index c8e16acaf..f14898316 100755 --- a/build/lib/scripts/chroot-and-upgrade +++ b/build/lib/scripts/chroot-and-upgrade @@ -34,7 +34,7 @@ set -- "${POSITIONAL_ARGS[@]}" # restore positional parameters if [ -z "$NO_SYNC" ]; then echo 'Syncing...' - umount -R /media/startos/next 2> /dev/null + umount -l /media/startos/next 2> /dev/null umount /media/startos/upper 2> /dev/null rm -rf /media/startos/upper /media/startos/next mkdir /media/startos/upper diff --git a/build/lib/scripts/upgrade b/build/lib/scripts/upgrade index a7559987f..309d0e9bb 100755 --- a/build/lib/scripts/upgrade +++ b/build/lib/scripts/upgrade @@ -24,7 +24,7 @@ fi unsquashfs -f -d / $1 boot -umount -R /media/startos/next 2> /dev/null || true +umount -l /media/startos/next 2> /dev/null || true umount /media/startos/upper 2> /dev/null || true umount /media/startos/lower 2> /dev/null || true @@ -47,14 +47,9 @@ mount --bind /tmp /media/startos/next/tmp mount --bind /dev /media/startos/next/dev mount --bind /sys /media/startos/next/sys mount --bind /proc /media/startos/next/proc -mount --bind /boot /media/startos/next/boot +mount --rbind /boot /media/startos/next/boot mount --bind /media/startos/root /media/startos/next/media/startos/root -if mountpoint /boot/efi 2>&1 > /dev/null; then - mkdir -p /media/startos/next/boot/efi - mount --bind /boot/efi /media/startos/next/boot/efi -fi - if mountpoint /sys/firmware/efi/efivars 2>&1 > /dev/null; then mount --bind /sys/firmware/efi/efivars /media/startos/next/sys/firmware/efi/efivars fi @@ -79,7 +74,7 @@ SIGN_FILE="$(ls -1 /media/startos/next/usr/lib/linux-kbuild-*/scripts/sign-file sync -umount -Rl /media/startos/next +umount -l /media/startos/next umount /media/startos/upper umount /media/startos/lower diff --git a/core/locales/i18n.yaml b/core/locales/i18n.yaml index 856617307..0b7a688a2 100644 --- a/core/locales/i18n.yaml +++ b/core/locales/i18n.yaml @@ -1379,6 +1379,21 @@ net.tor.client-error: fr_FR: "Erreur du client Tor : %{error}" pl_PL: "Błąd klienta Tor: %{error}" +# net/tunnel.rs +net.tunnel.timeout-waiting-for-add: + en_US: "timed out waiting for gateway %{gateway} to appear in database" + de_DE: "Zeitüberschreitung beim Warten auf das Erscheinen von Gateway %{gateway} in der Datenbank" + es_ES: "se agotó el tiempo esperando que la puerta de enlace %{gateway} aparezca en la base de datos" + fr_FR: "délai d'attente dépassé pour l'apparition de la passerelle %{gateway} dans la base de données" + pl_PL: "upłynął limit czasu oczekiwania na pojawienie się bramy %{gateway} w bazie danych" + +net.tunnel.timeout-waiting-for-remove: + en_US: "timed out waiting for gateway %{gateway} to be removed from database" + de_DE: "Zeitüberschreitung beim Warten auf das Entfernen von Gateway %{gateway} aus der Datenbank" + es_ES: "se agotó el tiempo esperando que la puerta de enlace %{gateway} sea eliminada de la base de datos" + fr_FR: "délai d'attente dépassé pour la suppression de la passerelle %{gateway} de la base de données" + pl_PL: "upłynął limit czasu oczekiwania na usunięcie bramy %{gateway} z bazy danych" + # net/wifi.rs net.wifi.ssid-no-special-characters: en_US: "SSID may not have special characters" diff --git a/core/src/net/gateway.rs b/core/src/net/gateway.rs index 33a608f14..91a012df1 100644 --- a/core/src/net/gateway.rs +++ b/core/src/net/gateway.rs @@ -1018,18 +1018,16 @@ async fn apply_policy_routing( }) .copied(); - // Flush and rebuild per-interface routing table. - // Clone all non-default routes from the main table so that LAN IPs on - // other subnets remain reachable when the priority-75 catch-all overrides - // default routing, then replace the default route with this interface's. - Command::new("ip") - .arg("route") - .arg("flush") - .arg("table") - .arg(&table_str) - .invoke(ErrorKind::Network) - .await - .log_err(); + // Rebuild per-interface routing table using `ip route replace` to avoid + // the connectivity gap that a flush+add cycle would create. We replace + // every desired route in-place (each replace is atomic in the kernel), + // then delete any stale routes that are no longer in the desired set. + + // Collect the set of desired non-default route prefixes (the first + // whitespace-delimited token of each `ip route show` line is the + // destination prefix, e.g. "192.168.1.0/24" or "10.0.0.0/8"). + let mut desired_prefixes = BTreeSet::::new(); + if let Ok(main_routes) = Command::new("ip") .arg("route") .arg("show") @@ -1044,11 +1042,14 @@ async fn apply_policy_routing( if line.is_empty() || line.starts_with("default") { continue; } + if let Some(prefix) = line.split_whitespace().next() { + desired_prefixes.insert(prefix.to_owned()); + } let mut cmd = Command::new("ip"); - cmd.arg("route").arg("add"); + cmd.arg("route").arg("replace"); for part in line.split_whitespace() { // Skip status flags that appear in route output but - // are not valid for `ip route add`. + // are not valid for `ip route replace`. if part == "linkdown" || part == "dead" { continue; } @@ -1058,10 +1059,11 @@ async fn apply_policy_routing( cmd.invoke(ErrorKind::Network).await.log_err(); } } - // Add default route via this interface's gateway + + // Replace the default route via this interface's gateway. { let mut cmd = Command::new("ip"); - cmd.arg("route").arg("add").arg("default"); + cmd.arg("route").arg("replace").arg("default"); if let Some(gw) = ipv4_gateway { cmd.arg("via").arg(gw.to_string()); } @@ -1075,6 +1077,40 @@ async fn apply_policy_routing( cmd.invoke(ErrorKind::Network).await.log_err(); } + // Delete stale routes: any non-default route in the per-interface table + // whose prefix is not in the desired set. + if let Ok(existing_routes) = Command::new("ip") + .arg("route") + .arg("show") + .arg("table") + .arg(&table_str) + .invoke(ErrorKind::Network) + .await + .and_then(|b| String::from_utf8(b).with_kind(ErrorKind::Utf8)) + { + for line in existing_routes.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with("default") { + continue; + } + let Some(prefix) = line.split_whitespace().next() else { + continue; + }; + if desired_prefixes.contains(prefix) { + continue; + } + Command::new("ip") + .arg("route") + .arg("del") + .arg(prefix) + .arg("table") + .arg(&table_str) + .invoke(ErrorKind::Network) + .await + .log_err(); + } + } + // Ensure global CONNMARK restore rules in mangle PREROUTING (forwarded // packets) and OUTPUT (locally-generated replies). Both are needed: // PREROUTING handles DNAT-forwarded traffic, OUTPUT handles replies from diff --git a/core/src/net/tunnel.rs b/core/src/net/tunnel.rs index da0f6d84c..694434514 100644 --- a/core/src/net/tunnel.rs +++ b/core/src/net/tunnel.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use clap::Parser; use imbl_value::InternedString; use patch_db::json_ptr::JsonPointer; @@ -8,7 +10,9 @@ use ts_rs::TS; use crate::GatewayId; use crate::context::{CliContext, RpcContext}; -use crate::db::model::public::{GatewayType, NetworkInterfaceInfo, NetworkInterfaceType}; +use crate::db::model::public::{ + GatewayType, NetworkInfo, NetworkInterfaceInfo, NetworkInterfaceType, +}; use crate::net::host::all_hosts; use crate::prelude::*; use crate::util::Invoke; @@ -139,6 +143,34 @@ pub async fn add_tunnel( .result?; } + // Wait for the sync loop to fully commit gateway state (addresses, hosts) + // to the database, with a 15-second timeout. + if tokio::time::timeout(Duration::from_secs(15), async { + let mut watch = ctx + .db + .watch("/public/serverInfo/network".parse::().unwrap()) + .await + .typed::(); + loop { + if watch + .peek()? + .as_gateways() + .as_idx(&iface) + .and_then(|g| g.as_ip_info().transpose_ref()) + .is_some() + { + break; + } + watch.changed().await?; + } + Ok::<_, Error>(()) + }) + .await + .is_err() + { + tracing::warn!("{}", t!("net.tunnel.timeout-waiting-for-add", gateway = iface.as_str())); + } + Ok(iface) } @@ -224,5 +256,27 @@ pub async fn remove_tunnel( .await .result?; + // Wait for the sync loop to fully commit gateway removal to the database, + // with a 15-second timeout. + if tokio::time::timeout(Duration::from_secs(15), async { + let mut watch = ctx + .db + .watch("/public/serverInfo/network".parse::().unwrap()) + .await + .typed::(); + loop { + if watch.peek()?.as_gateways().as_idx(&id).is_none() { + break; + } + watch.changed().await?; + } + Ok::<_, Error>(()) + }) + .await + .is_err() + { + tracing::warn!("{}", t!("net.tunnel.timeout-waiting-for-remove", gateway = id.as_str())); + } + Ok(()) }