diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 651597e..5a6b151 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -30,7 +30,7 @@ jobs: uses: hadolint/hadolint-action@v3.4.0 with: dockerfile: Dockerfile - ignore: DL3008 + ignore: DL3008,DL3067 failure-threshold: warning - name: Validate JSON and YML files diff --git a/Dockerfile b/Dockerfile index c8d1623..4c56643 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,12 @@ # syntax=docker/dockerfile:1 -FROM qemux/qemu-host:2.06 AS builder -FROM debian:trixie-slim +FROM qemux/qemu-host:2.06 AS host +FROM scratch -ARG TARGETARCH -ARG TARGETPLATFORM +COPY --from=qemux/qemu:7.45 / / ARG VERSION_ARG="0.0" -ARG VERSION_WSD="0.4.2" ARG VERSION_CSTRUCT="4.7" -ARG VERSION_PASST="2026_07_28" ARG DEBCONF_NOWARNINGS="yes" ARG DEBIAN_FRONTEND="noninteractive" @@ -20,44 +17,10 @@ RUN < [!NOTE] > This can be used to enable the facial recognition function in Synology Photos, but does not provide hardware transcoding for video. +### How do I enable dynamic memory allocation? + + By default, the DSM is allocated the full amount of RAM configured via `RAM_SIZE` for its entire lifetime. + + However, you can enable [memory ballooning](https://github.com/qemus/qemu/blob/master/docs/ballooning.md) if you want the container to dynamically reclaim unused guest RAM based on host memory pressure. + ### How do I install a specific version of vDSM? By default, version 7.2 will be installed, but if you prefer an older version, you can add the download URL of the `.pat` file to your compose file as follows: diff --git a/src/check.sh b/src/check.sh index a7ba38f..6bfb91f 100644 --- a/src/check.sh +++ b/src/check.sh @@ -8,20 +8,24 @@ cd /run : "${NETWORK:="Y"}" [ -f "/run/shm/qemu.end" ] && echo "QEMU is shutting down..." && exit 1 + +# Treat the startup window as healthy so container health checks do not restart +# the service before QEMU has had time to publish its PID. [ ! -s "/run/shm/qemu.pid" ] && echo "QEMU is not running yet..." && exit 0 + disabled "$NETWORK" && echo "Networking is disabled." && exit 0 file="/run/shm/dsm.url" -address="/run/shm/qemu.ip" +address="/run/shm/qemu.host" gateway="/run/shm/qemu.gw" # dsm.url is written only after the guest agent reports both the DSM # address and its configured HTTP port. -[ ! -s "$file" ] && echo "DSM has not enabled networking yet..." && exit 0 +[ ! -s "$file" ] && echo "DSM has not enabled networking yet..." && exit 0 location=$(<"$file") -if ! curl -m 20 -ILfSs "http://$location/" > /dev/null; then +if ! curl -m 20 -LfSs -o /dev/null "http://$location/"; then # In DHCP mode the firewall must allow the container address; with port # forwarding it must allow the internal gateway used to reach the guest. diff --git a/src/config.sh b/src/config.sh index e21358e..8a10b47 100644 --- a/src/config.sh +++ b/src/config.sh @@ -2,6 +2,7 @@ set -Eeuo pipefail : "${QMP:=""}" +: "${UUID:=""}" : "${MONITOR:=""}" DEF_OPTS="-nodefaults -boot strict=on" @@ -39,11 +40,18 @@ configureMonitor() { configureMachine() { + local smm="off" + enabled "${SMM:-}" && smm="on" + # Disable firmware and chipset features that Virtual DSM does not use and # that can introduce extra devices or timing differences. - MAC_OPTS="-machine type=$MACHINE,smm=off,usb=off" + MAC_OPTS="-machine type=$MACHINE,smm=$smm,usb=off" MAC_OPTS+=",vmport=off,dump-guest-core=off,hpet=off${KVM_OPTS}" + UUID=$(strip "$UUID") + [ -n "$UUID" ] && MAC_OPTS+=" -uuid $UUID" + [ -n "${SM_BIOS:-}" ] && MAC_OPTS+=" $SM_BIOS" + return 0 } @@ -52,7 +60,15 @@ configureVirtioDevices() { local bus bus=$(getPciBus) - DEV_OPTS="-device virtio-balloon-pci,id=balloon0,bus=$bus,addr=0x4" + # Keep the existing balloon device by default. When dynamic ballooning is + # enabled, expose guest statistics and a dedicated QMP control socket. + if ! enabled "${BALLOONING:-}"; then + DEV_OPTS="-device virtio-balloon-pci,id=balloon0,bus=$bus,addr=0x4" + else + MON_OPTS+=" -qmp unix:${BALLOONING_SOCKET},server=on,wait=off" + DEV_OPTS="-device virtio-balloon-pci,free-page-reporting=on,guest-stats-polling-interval=1,id=balloon0,bus=$bus,addr=0x4" + fi + DEV_OPTS+=" -object rng-random,id=objrng0,filename=/dev/urandom" DEV_OPTS+=" -device virtio-rng-pci,rng=objrng0,id=rng0,bus=$bus,addr=0x1c" @@ -61,7 +77,8 @@ configureVirtioDevices() { buildArguments() { - ARGS="$DEF_OPTS $CPU_OPTS $RAM_OPTS $MAC_OPTS $DISPLAY_OPTS $MON_OPTS $SERIAL_OPTS $NET_OPTS $DISK_OPTS $DEV_OPTS $ARGUMENTS" + ARGS="$DEF_OPTS $CPU_OPTS $RAM_OPTS $MAC_OPTS $DISPLAY_OPTS $MON_OPTS $SERIAL_OPTS $NET_OPTS $DISK_OPTS $BOOT_OPTS $DEV_OPTS $ARGUMENTS" + # Collapse whitespace after optional argument groups are assembled so empty # features cannot leave malformed spacing in the final QEMU command. ARGS=$(echo "$ARGS" | sed 's/\t/ /g' | tr -s ' ') diff --git a/src/disk.sh b/src/disk.sh deleted file mode 100644 index 35c6953..0000000 --- a/src/disk.sh +++ /dev/null @@ -1,855 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -# Docker environment variables - -: "${DISK_IO:="native"}" # I/O Mode, can be set to 'native', 'threads' or 'io_uring' -: "${DISK_FMT:="raw"}" # Disk file format, 'raw' by default for best performance -: "${DISK_TYPE:=""}" # Device type to be used, "sata", "nvme", "blk" or "scsi" -: "${DISK_FLAGS:=""}" # Specifies the options for use with the qcow2 disk format -: "${DISK_OPTIONS:=""}" # Specifies additional options for the QEMU disk device -: "${DISK_CACHE:="none"}" # Caching mode, can be set to 'writeback' for better performance -: "${DISK_DISCARD:="unmap"}" # Controls whether unmap (TRIM) commands are passed to the host. -: "${DISK_ROTATION:="1"}" # Rotation rate, set to 1 for SSD storage and increase for HDD - -# Sanitize all variables -DISK_IO=$(strip "$DISK_IO") -DISK_FMT=$(strip "$DISK_FMT") -DISK_TYPE=$(strip "$DISK_TYPE") -DISK_FLAGS=$(strip "$DISK_FLAGS") -DISK_OPTIONS=$(strip "$DISK_OPTIONS") -DISK_CACHE=$(strip "$DISK_CACHE") -DISK_DISCARD=$(strip "$DISK_DISCARD") -DISK_ROTATION=$(strip "$DISK_ROTATION") - -BOOT="$STORAGE/$BASE.boot.img" -SYSTEM="$STORAGE/$BASE.system.img" - -# The boot and system images are installation artifacts, not optional data -# disks, and must exist before any user storage is attached. -[ ! -s "$BOOT" ] && error "Virtual DSM boot-image does not exist ($BOOT)" && exit 81 -[ ! -s "$SYSTEM" ] && error "Virtual DSM system-image does not exist ($SYSTEM)" && exit 82 - -if ! setOwner "$BOOT"; then - warn "failed to set the owner for \"$BOOT\" !" -fi - -if ! setOwner "$SYSTEM"; then - warn "failed to set the owner for \"$SYSTEM\" !" -fi - -fmt2ext() { - local diskFmt="$1" - - case "${diskFmt,,}" in - qcow2) echo "qcow2" ;; - raw) echo "img" ;; - *) error "Unrecognized disk format: $diskFmt" && exit 78 ;; - esac -} - -ext2fmt() { - local diskExt="$1" - - case "${diskExt,,}" in - qcow2) echo "qcow2" ;; - img) echo "raw" ;; - *) error "Unrecognized file extension: .$diskExt" && exit 78 ;; - esac -} - -getSize() { - - local diskFile="$1" - local diskExt diskFmt size - - diskExt=$(echo "${diskFile//*./}" | sed 's/^.*\.//') - diskFmt=$(ext2fmt "$diskExt") - - case "${diskFmt,,}" in - raw) - stat -c%s "$diskFile" - ;; - - qcow2) - size=$(qemu-img info --output=json -f "$diskFmt" "$diskFile" | jq -r '."virtual-size" // empty') - if [[ ! "$size" =~ ^[0-9]+$ ]]; then - error "Failed to determine virtual size of $diskFile" - exit 78 - fi - echo "$size" - ;; - - *) - error "Unrecognized disk format: $diskFmt" - exit 78 - ;; - esac -} - -isCow() { - local fs="$1" - - if [[ "${fs,,}" == "btrfs" ]]; then - return 0 - fi - - return 1 -} - -supportsDirect() { - local fs="$1" - - if [[ "${fs,,}" == "ecryptfs" || "${fs,,}" == "tmpfs" ]]; then - return 1 - fi - - return 0 -} - -validDiskType() { - - case "${1,,}" in - "ide" | "sata" | "nvme" | "usb" | "scsi" | "blk" | \ - "virtio-blk" | "virtio-scsi" | "auto" | "none" ) - return 0 - ;; - esac - - return 1 -} - -allocateRaw() { - - local diskFile="$1" - local dataSize="$2" - - if disabled "$ALLOCATE"; then - truncate -s "$dataSize" "$diskFile" - return $? - fi - - # Prefer real allocation, retry with zero-range allocation where supported, - # and fall back to a sparse file when the host filesystem rejects both. - fallocate -l "$dataSize" "$diskFile" &>/dev/null && return 0 - fallocate -l -x "$dataSize" "$diskFile" && return 0 - truncate -s "$dataSize" "$diskFile" || return 1 - - return 0 -} - -getDiskOptions() { - - local fs="$1" - local diskFmt="$2" - local diskParam="$DISK_ALLOC" - - isCow "$fs" && diskParam+=",nocow=on" - - if [[ "${diskFmt,,}" != "raw" ]]; then - [ -n "$DISK_FLAGS" ] && diskParam+=",$DISK_FLAGS" - fi - - echo "$diskParam" - return 0 -} - -normalizeSize() { - - local diskSpace="$1" - local diskDesc="$2" - local dir="$3" - - local free dataSize - local spare=1073741824 - - # Dynamic sizes are resolved once from current free space. max reserves one - # GiB for host metadata and container activity; half uses half the space. - if [[ "${diskSpace,,}" == "max" || "${diskSpace,,}" == "half" ]]; then - - free=$(df --output=avail -B 1 "$dir" | tail -n 1) - - if [[ "${diskSpace,,}" == "max" ]]; then - free=$(( free - spare )) - else - free=$(( free / 2 )) - fi - - (( free < spare )) && free="$spare" - local gb=$(( free / 1073741825 )) - diskSpace="${gb}G" - - fi - - local space="${diskSpace// /}" - [ -z "$space" ] && space="256G" - [ -z "${space//[0-9. ]}" ] && space="${space}G" - space=$(echo "${space^^}" | sed 's/MB/M/g;s/GB/G/g;s/TB/T/g') - - if ! numfmt --from=iec "$space" &>/dev/null; then - error "Invalid value for ${diskDesc^^}_SIZE: $diskSpace" && exit 73 - fi - - dataSize=$(numfmt --from=iec "$space") - - if (( dataSize < 6442450944 )); then - error "Please increase the ${diskDesc^^}_SIZE variable to at least 6 GB." && exit 73 - fi - - echo "$space" - return 0 -} - -freeSpace() { - - local path="$1" - - local base - base=$(baseDir "$path") - - if ! available=$(df --output=avail -B 1 "$path" | tail -n 1); then - error "Failed to check free space in $base." - exit 76 - fi - - if [[ ! "$available" =~ ^[0-9]+$ ]]; then - error "Failed to check free space in $base." - exit 76 - fi - - return 0 -} - -createDisk() { - - local diskFile="$1" - local diskSpace="$2" - local diskDesc="$3" - local diskFmt="$4" - local fs="$5" - - local gb dir base - local attributes available - - rm -f "$diskFile" - - local dataSize - dataSize=$(numfmt --from=iec "$diskSpace") - - if ! disabled "$ALLOCATE"; then - - # Check free diskspace - dir=$(dirname "$diskFile") - base=$(baseDir "$dir") - - freeSpace "$dir" - - if (( dataSize > available )); then - gb=$(formatBytes "$available") - error "Not enough free space to create a $diskDesc of ${diskSpace/G/ GB} in $base, it has only $gb available..." - error "Please specify a smaller ${diskDesc^^}_SIZE or disable preallocation by setting ALLOCATE=N." && exit 76 - fi - - fi - - html "Creating a $diskDesc image..." - info "Creating a ${diskSpace/G/ GB} $DISK_STYLE $diskDesc image in $diskFmt format..." - - local failure="Could not create a $DISK_STYLE $diskFmt $diskDesc image of ${diskSpace/G/ GB} ($diskFile)" - - case "${diskFmt,,}" in - raw) - - if isCow "$fs"; then - if ! touch "$diskFile"; then - error "$failure" && exit 77 - fi - { chattr +C "$diskFile"; } || : - fi - - if ! allocateRaw "$diskFile" "$dataSize"; then - rm -f "$diskFile" - error "$failure" && exit 77 - fi - ;; - qcow2) - - local diskParam - diskParam=$(getDiskOptions "$fs" "$diskFmt") - - if ! qemu-img create -f "$diskFmt" -o "$diskParam" -- "$diskFile" "$dataSize" ; then - rm -f "$diskFile" - error "$failure" && exit 70 - fi - ;; - esac - - if isCow "$fs"; then - attributes=$(lsattr "$diskFile") - if [[ "$attributes" != *"C"* ]]; then - error "Failed to disable COW for $diskDesc image $diskFile on ${fs^^} filesystem (returned $attributes)" - fi - fi - - return 0 -} - -resizeDisk() { - - local diskFile="$1" - local diskSpace="$2" - local diskDesc="$3" - local diskFmt="$4" - local fs="$5" - - local gb dir base dataSize - local available currentSize - - currentSize=$(getSize "$diskFile") || exit 71 - dataSize=$(numfmt --from=iec "$diskSpace") - local required=$(( dataSize - currentSize )) - (( required < 1 )) && error "Shrinking disks is not supported yet, please increase ${diskDesc^^}_SIZE." && exit 71 - - if ! disabled "$ALLOCATE"; then - - # Check free diskspace - dir=$(dirname "$diskFile") - base=$(baseDir "$dir") - - freeSpace "$dir" - - if (( required > available )); then - gb=$(formatBytes "$available") - error "Not enough free space to resize $diskDesc to ${diskSpace/G/ GB} in $base, it has only $gb available.." - error "Please specify a smaller ${diskDesc^^}_SIZE or disable preallocation by setting ALLOCATE=N." && exit 74 - fi - - fi - - gb=$(formatBytes "$currentSize") - MSG="Resizing $diskDesc from $gb to ${diskSpace/G/ GB}..." - info "$MSG" && html "$MSG" - - local failure="Could not resize the $DISK_STYLE $diskFmt $diskDesc image from ${gb} to ${diskSpace/G/ GB} ($diskFile)" - - case "${diskFmt,,}" in - raw) - - if ! allocateRaw "$diskFile" "$dataSize"; then - error "$failure" && exit 75 - fi - ;; - qcow2) - - if ! qemu-img resize -f "$diskFmt" "--$DISK_ALLOC" "$diskFile" "$dataSize" ; then - error "$failure" && exit 72 - fi - - ;; - esac - - return 0 -} - -convertDisk() { - - local sourceFile="$1" - local sourceFmt="$2" - local destinationFile="$3" - local destinationFmt="$4" - local diskBase="$5" - local diskDesc="$6" - local fs="$7" - local tmpFile="$diskBase.tmp" - - local gb dir base attributes - local available currentSize - - [ -f "$destinationFile" ] && error "Conversion failed, destination file $destinationFile already exists?" && exit 79 - [ ! -f "$sourceFile" ] && error "Conversion failed, source file $sourceFile does not exist?" && exit 79 - - rm -f "$tmpFile" - - dir=$(dirname "$tmpFile") - base=$(baseDir "$dir") - - if ! disabled "$ALLOCATE"; then - - # Check free diskspace - currentSize=$(getSize "$sourceFile") || exit 79 - - freeSpace "$dir" - - if (( currentSize > available )); then - gb=$(formatBytes "$available") - error "Not enough free space to convert $diskDesc to $destinationFmt in $base, it has only $gb available..." - error "Please free up some disk space or disable preallocation by setting ALLOCATE=N." && exit 76 - fi - - fi - - local msg="Converting $diskDesc to $destinationFmt" - html "$msg..." - info "$msg, please wait until completed..." - - local convertFlags="-p" - local diskParam - diskParam=$(getDiskOptions "$fs" "$destinationFmt") - - if [[ "$destinationFmt" != "raw" ]]; then - if disabled "$ALLOCATE"; then - convertFlags+=" -c" - fi - fi - - # shellcheck disable=SC2086 - if ! qemu-img convert -f "$sourceFmt" $convertFlags -o "$diskParam" -O "$destinationFmt" -- "$sourceFile" "$tmpFile"; then - rm -f "$tmpFile" - error "Failed to convert $DISK_STYLE $diskDesc image to $destinationFmt format in $base, is there enough space available?" && exit 79 - fi - - if [[ "$destinationFmt" == "raw" ]]; then - if ! disabled "$ALLOCATE"; then - - # qemu-img may leave converted raw output sparse despite requested - # preallocation, so allocate its final length explicitly afterward. - # Work around qemu-img bug - if ! currentSize=$(stat -c%s "$tmpFile"); then - error "Failed to determine converted image size: $tmpFile" - exit 79 - fi - - if ! fallocate -l "$currentSize" "$tmpFile" &>/dev/null; then - if ! fallocate -l -x "$currentSize" "$tmpFile"; then - error "Failed to allocate $currentSize bytes for $diskDesc image $tmpFile" - fi - fi - fi - fi - - # Publish the converted image before deleting the original so a failed - # conversion or rename never destroys the only usable disk. - if ! mv "$tmpFile" "$destinationFile"; then - error "Failed to move converted $diskDesc image to $destinationFile." - exit 79 - fi - - if ! rm -f "$sourceFile"; then - error "Failed to remove old $diskDesc image $sourceFile." - exit 79 - fi - - if isCow "$fs"; then - attributes=$(lsattr "$destinationFile") - if [[ "$attributes" != *"C"* ]]; then - error "Failed to disable COW for $diskDesc image $destinationFile on ${fs^^} filesystem (returned $attributes)" - fi - fi - - msg="Conversion of $diskDesc" - info "$msg to $destinationFmt completed successfully!" - - return 0 -} - -checkFS () { - - local fs="$1" - local diskFile="$2" - local diskDesc="$3" - - local dir base - local attributes - - dir=$(dirname "$diskFile") - base=$(baseDir "$dir") - [ ! -d "$dir" ] && return 0 - - if [[ "${fs,,}" == "overlay"* && "${ENGINE,,}" == "docker" ]]; then - warn "the filesystem of $base is OverlayFS, this usually means it was binded to an invalid path!" - fi - - if [[ "${fs,,}" == "fuse"* ]]; then - warn "the filesystem of $base is FUSE, this extra layer will negatively affect performance!" - fi - - # Filesystems without O_DIRECT support require threaded I/O and writeback - # caching; native AIO with cache=none would fail at runtime. - if ! supportsDirect "$fs"; then - warn "the filesystem of $base is $fs, which does not support O_DIRECT mode, adjusting settings..." - fi - - if isCow "$fs"; then - if [ -f "$diskFile" ]; then - attributes=$(lsattr "$diskFile") - if [[ "$attributes" != *"C"* ]]; then - warn "COW (copy on write) is not disabled for $diskDesc image file $diskFile, this is recommended on ${fs^^} filesystems!" - fi - fi - fi - - return 0 -} - -createDevice () { - - local diskFile="$1" - local diskType="$2" - local diskIndex="$3" - local diskAddress="$4" - local diskFmt="$5" - local diskIo="$6" - local diskCache="$7" - local diskSerial="$8" - local diskSectors="$9" - - local bus - bus=$(getPciBus) - - local options="" - [ -n "$DISK_OPTIONS" ] && options=",${DISK_OPTIONS#,}" - - local bootIndex="" - local diskId="data$diskIndex" - [ -n "$diskIndex" ] && bootIndex=",bootindex=$diskIndex" - local result=" -drive file=$diskFile,id=$diskId,format=$diskFmt,cache=$diskCache,aio=$diskIo,discard=$DISK_DISCARD,detect-zeroes=on" - - case "${diskType,,}" in - "none" ) ;; - "auto" ) - echo "$result" - ;; - "usb" ) - result+=",if=none \ - -device usb-storage,drive=${diskId}${bootIndex}${diskSerial}${diskSectors}${options}" - echo "$result" - ;; - "nvme" ) - result+=",if=none \ - -device nvme,drive=${diskId}${bootIndex},serial=deadbeaf${diskIndex}${diskSerial}${diskSectors}${options}" - echo "$result" - ;; - "ide" | "sata" ) - result+=",if=none \ - -device ich9-ahci,id=ahci${diskIndex},addr=$diskAddress \ - -device ide-hd,drive=${diskId},bus=ahci$diskIndex.0,rotation_rate=$DISK_ROTATION${bootIndex}${diskSerial}${diskSectors}${options}" - echo "$result" - ;; - "blk" | "virtio-blk" ) - result+=",if=none \ - -device virtio-blk-pci,drive=${diskId},bus=$bus,addr=$diskAddress,iothread=io2${bootIndex}${diskSerial}${diskSectors}${options}" - echo "$result" - ;; - "scsi" | "virtio-scsi" ) - result+=",if=none \ - -device virtio-scsi-pci,id=${diskId}b,bus=$bus,addr=$diskAddress,iothread=io2,hotplug=off \ - -device scsi-hd,drive=${diskId},bus=${diskId}b.0,channel=0,scsi-id=0,lun=0,rotation_rate=$DISK_ROTATION${bootIndex}${diskSerial}${diskSectors}${options}" - echo "$result" - ;; - esac - - return 0 -} - -finishDisks () { - - case "${DISK_TYPE,,}" in - "blk" | "scsi" | "virtio-blk" | "virtio-scsi" ) - # VirtIO block and SCSI devices share one dedicated I/O thread, which - # must be declared exactly once regardless of disk count. - [[ "$DISK_OPTS" != *" -object iothread,id=io2"* ]] && DISK_OPTS+=" -object iothread,id=io2" ;; - esac - - return 0 -} - -addDisk () { - - local diskBase="$1" - local diskType="$2" - local diskDesc="$3" - local diskSpace="$4" - local diskIndex="$5" - local diskAddress="$6" - local diskFmt="$7" - local diskIo="$8" - local diskCache="$9" - - local fs dir used space - local diskExt dataSize - local available currentSize - local previousExt - - diskExt=$(fmt2ext "$diskFmt") - local diskFile="$diskBase.$diskExt" - - dir=$(dirname "$diskFile") - [ ! -d "$dir" ] && return 0 - - space=$(normalizeSize "$diskSpace" "$diskDesc" "$dir") - dataSize=$(numfmt --from=iec "$space") - - if ! fs=$(stat -f -c %T "$dir"); then - error "Failed to determine filesystem type of \"$dir\" !" - return 1 - fi - - checkFS "$fs" "$diskFile" "$diskDesc" || exit $? - - if ! supportsDirect "$fs"; then - diskIo="threads" - diskCache="writeback" - fi - - if [ ! -f "$diskFile" ] || [ ! -s "$diskFile" ]; then - - if [[ "${diskFmt,,}" != "raw" ]]; then - local previousFmt="raw" - else - local previousFmt="qcow2" - fi - - previousExt=$(fmt2ext "$previousFmt") - - # Treat a disk in the other supported format as the same logical disk and - # convert it automatically instead of creating an empty replacement. - if [ -f "$diskBase.$previousExt" ] && - [ -s "$diskBase.$previousExt" ]; then - convertDisk "$diskBase.$previousExt" "$previousFmt" "$diskFile" "$diskFmt" "$diskBase" "$diskDesc" "$fs" || exit $? - fi - - fi - - if [ -f "$diskFile" ] && [ -s "$diskFile" ]; then - - currentSize=$(getSize "$diskFile") || exit 71 - - if (( dataSize > currentSize )); then - - resizeDisk "$diskFile" "$space" "$diskDesc" "$diskFmt" "$fs" || exit $? - - else - - if (( dataSize < currentSize )); then - - if [[ "${diskSpace,,}" != "max" && "${diskSpace,,}" != "half" ]]; then - info "You decreased the ${diskDesc^^}_SIZE variable to ${diskSpace/G/ GB} but shrinking disks is not supported, will be ignored..." - fi - - fi - fi - - else - - createDisk "$diskFile" "$space" "$diskDesc" "$diskFmt" "$fs" || exit $? - - fi - - # Sparse disks can promise more guest capacity than the host can currently - # satisfy, so report the future shortfall without blocking startup. - if [ -f "$diskFile" ] && disabled "$ALLOCATE"; then - - currentSize=$(getSize "$diskFile") || exit 73 - used=$(du -sB 1 "$diskFile" | cut -f1) - available=$(df --output=avail -B 1 "$dir" | tail -n 1) - local missing=$(( currentSize - used - available )) - (( missing < 0 )) && missing=0 - - if (( missing > 0 )); then - - local gb base - - gb=$(formatBytes "$available") - base=$(baseDir "$dir") - missing=$(formatBytes "$missing") - currentSize=$(formatBytes "$currentSize") - local msg="The virtual size of the ${diskDesc,,} is $currentSize" - - if [ -n "$used" ] && [[ "$used" != "0" ]]; then - used=$(formatBytes "$used") - msg+=" (of which $used is used)" - fi - - info "$msg, but there is only $gb of free space remaining in $base now." - info "Please consider making at least $missing more space available in $base for future expansions." - - fi - fi - - if [ -f "$diskFile" ]; then - if ! setOwner "$diskFile"; then - warn "failed to set the owner for \"$diskFile\" !" - fi - fi - - DISK_OPTS+=$(createDevice "$diskFile" "$diskType" "$diskIndex" "$diskAddress" "$diskFmt" "$diskIo" "$diskCache" "" "") - - return 0 -} - -addDevice () { - - local diskDev="$1" - local diskType="$2" - local diskIndex="$3" - local diskAddress="$4" - - local sectors="" - local devType - devType=$(lsblk -no TYPE "$diskDev" 2>/dev/null | head -n1) - - [ -z "$diskDev" ] && return 0 - [ ! -b "$diskDev" ] && error "Device $diskDev cannot be found! Please add it to the 'devices' section of your compose file." && exit 55 - - # DSM may reject whole-disk passthrough when QEMU is given explicit sector - # geometry; partitions need it to preserve non-512-byte host geometry. - # Only detect and apply sector sizes for partitions, not whole disks. - # Whole disk passthrough with explicit sector sizes causes DSM not to recognize the disk. - if [[ "$devType" == "part" ]]; then - - local result - local logical="" physical="" - - result=$(fdisk -l "$diskDev" 2>/dev/null | grep -m 1 -o "(logical/physical): .*" | cut -c 21- || true) - - if [ -n "$result" ]; then - logical="${result%% *}" - physical=$(echo "$result" | grep -m 1 -o "/ .*" | cut -c 3- || true) - physical="${physical%% *}" - fi - - if [ -z "$logical" ] || [ -z "$physical" ]; then - warn "Failed to determine the sector size for $diskDev" - elif [[ "$physical" != "512" ]]; then - sectors=",logical_block_size=$logical,physical_block_size=$physical" - fi - - fi - - DISK_OPTS+=$(createDevice "$diskDev" "$diskType" "$diskIndex" "$diskAddress" "raw" "$DISK_IO" "$DISK_CACHE" "" "$sectors") - - return 0 -} - -[ -z "${DISK_OPTS:-}" ] && DISK_OPTS="" -[ -z "${DISK_TYPE:-}" ] && DISK_TYPE="scsi" -[ -z "${DISK_NAME:-}" ] && DISK_NAME="data" -[ -z "${DISK_DISABLE:-}" ] && DISK_DISABLE="" - -if ! enabled "$DISK_DISABLE"; then - msg="Initializing disks..." - enabled "$DEBUG" && echo "$msg" -fi - -if [[ "${DISK_IO,,}" == "native" && "${DISK_CACHE,,}" != "none" && "${DISK_CACHE,,}" != "directsync" ]]; then - warn "DISK_IO=native requires direct I/O caching, using DISK_IO=threads with DISK_CACHE=$DISK_CACHE." - DISK_IO="threads" -fi - -case "${DISK_DISCARD,,}" in - "y" | "yes" | "true" | "1" | "on" | "unmap" ) - DISK_DISCARD="unmap" ;; - - "n" | "no" | "false" | "0" | "off" | "ignore" ) - DISK_DISCARD="ignore" ;; - - * ) - warn "Invalid DISK_DISCARD value '$DISK_DISCARD', using 'unmap'." - DISK_DISCARD="unmap" ;; -esac - -if [[ ! "$DISK_ROTATION" =~ ^[0-9]+$ ]]; then - warn "Invalid DISK_ROTATION value '$DISK_ROTATION', using 1." - DISK_ROTATION="1" -fi - -DISK_FMT="${DISK_FMT,,}" - -case "$DISK_FMT" in - "raw" | "qcow2" ) ;; - * ) error "Invalid DISK_FMT specified, value \"$DISK_FMT\" is not recognized!" && exit 78 ;; -esac - -if ! validDiskType "$DISK_TYPE"; then - error "Invalid DISK_TYPE specified, value \"$DISK_TYPE\" is not recognized!" - exit 80 -fi - -if [[ "$DISK_FLAGS" =~ [[:space:]] ]]; then - error "Invalid DISK_FLAGS value '$DISK_FLAGS', spaces are not allowed." - exit 78 -fi - -if [[ "$DISK_OPTIONS" =~ [[:space:]] ]]; then - error "Invalid DISK_OPTIONS value '$DISK_OPTIONS', spaces are not allowed." - exit 78 -fi - -if [ -z "$ALLOCATE" ]; then - ALLOCATE="N" -fi - -if disabled "$ALLOCATE"; then - DISK_STYLE="growable" - DISK_ALLOC="preallocation=off" -else - DISK_STYLE="preallocated" - DISK_ALLOC="preallocation=falloc" -fi - -# Reserve the first two boot indexes and PCI addresses for the managed boot -# and system images; user disks begin at index 3. -DISK_OPTS+=$(createDevice "$BOOT" "$DISK_TYPE" "1" "0xa" "raw" "$DISK_IO" "$DISK_CACHE" "" "") -DISK_OPTS+=$(createDevice "$SYSTEM" "$DISK_TYPE" "2" "0xb" "raw" "$DISK_IO" "$DISK_CACHE" "" "") - -if enabled "$DISK_DISABLE"; then - finishDisks && return 0 -fi - -DISK1_FILE="$STORAGE/${DISK_NAME}" -DISK2_FILE="/storage2/${DISK_NAME}2" -DISK3_FILE="/storage3/${DISK_NAME}3" -DISK4_FILE="/storage4/${DISK_NAME}4" - -: "${DISK2_SIZE:=""}" -: "${DISK3_SIZE:=""}" -: "${DISK4_SIZE:=""}" - -: "${DEVICE:=""}" # Docker variables to passthrough a block device, like /dev/vdc1. -: "${DEVICE2:=""}" -: "${DEVICE3:=""}" -: "${DEVICE4:=""}" - -[ -z "$DEVICE" ] && [ -b "/disk" ] && DEVICE="/disk" -[ -z "$DEVICE" ] && [ -b "/disk1" ] && DEVICE="/disk1" -[ -z "$DEVICE2" ] && [ -b "/disk2" ] && DEVICE2="/disk2" -[ -z "$DEVICE3" ] && [ -b "/disk3" ] && DEVICE3="/disk3" -[ -z "$DEVICE4" ] && [ -b "/disk4" ] && DEVICE4="/disk4" - -[ -z "$DEVICE" ] && [ -b "/dev/disk1" ] && DEVICE="/dev/disk1" -[ -z "$DEVICE2" ] && [ -b "/dev/disk2" ] && DEVICE2="/dev/disk2" -[ -z "$DEVICE3" ] && [ -b "/dev/disk3" ] && DEVICE3="/dev/disk3" -[ -z "$DEVICE4" ] && [ -b "/dev/disk4" ] && DEVICE4="/dev/disk4" - -DISK_FILES=( "$DISK1_FILE" "$DISK2_FILE" "$DISK3_FILE" "$DISK4_FILE" ) -DISK_DESCS=( "disk" "disk2" "disk3" "disk4" ) -DISK_SIZES=( "$DISK_SIZE" "$DISK2_SIZE" "$DISK3_SIZE" "$DISK4_SIZE" ) -DISK_DEVICES=( "$DEVICE" "$DEVICE2" "$DEVICE3" "$DEVICE4" ) -DISK_INDEXES=( "3" "4" "5" "6" ) -DISK_ADDRESSES=( "0xc" "0xd" "0xe" "0xf" ) - -# A passed-through block device takes precedence over the image-file slot -# with the same number. -for i in "${!DISK_FILES[@]}"; do - - if [ -n "${DISK_DEVICES[i]}" ]; then - addDevice "${DISK_DEVICES[i]}" "$DISK_TYPE" "${DISK_INDEXES[i]}" "${DISK_ADDRESSES[i]}" || exit $? - else - addDisk "${DISK_FILES[i]}" "$DISK_TYPE" "${DISK_DESCS[i]}" "${DISK_SIZES[i]}" "${DISK_INDEXES[i]}" "${DISK_ADDRESSES[i]}" "$DISK_FMT" "$DISK_IO" "$DISK_CACHE" || exit $? - fi - -done - -finishDisks - -return 0 diff --git a/src/display.sh b/src/display.sh index dbad929..0db7460 100644 --- a/src/display.sh +++ b/src/display.sh @@ -65,9 +65,27 @@ if [ ! -c "$RENDERNODE" ] || [ ! -r "$RENDERNODE" ] || [ ! -w "$RENDERNODE" ]; t warn "render device '${RENDERNODE}' is unavailable or inaccessible." fi +addDsmPackage() { + + local pkg=$1 + local desc=$2 + + if ! apt-mark showinstall | grep -qx "$pkg"; then + [ -z "$COUNTRY" ] && setCountry + + # Use a mainland mirror only for on-demand package installation, avoiding + # slow or inaccessible Debian endpoints in that region. + if [[ "${COUNTRY^^}" == "CN" ]]; then + sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/debian.sources + fi + fi + + addPackage "$pkg" "$desc" +} + # Install acceleration packages lazily so non-GPU deployments keep the base # image small and do not require OpenGL modules. -addPackage "xserver-xorg-video-intel" "Intel GPU drivers" -addPackage "qemu-system-modules-opengl" "OpenGL module" +addDsmPackage "xserver-xorg-video-intel" "Intel GPU drivers" +addDsmPackage "qemu-system-modules-opengl" "OpenGL module" return 0 diff --git a/src/entry.sh b/src/entry.sh index 39cd0dd..2b984c1 100755 --- a/src/entry.sh +++ b/src/entry.sh @@ -5,6 +5,17 @@ set -Eeuo pipefail : "${APP:="Virtual DSM"}" : "${SUPPORT:="https://github.com/vdsm/virtual-dsm"}" +: "${USB:="N"}" +: "${AUDIO:="N"}" +: "${VGA:="none"}" +: "${DISPLAY:="none"}" +: "${WEB_PORT:="5000"}" +: "${DISK_OFFSET:="2"}" +: "${DISK_SIZE:="256G"}" +: "${RAM_MINIMUM:="1G"}" +: "${DISK_MINIMUM:="6G"}" +: "${BOOT_MODE:="legacy"}" + cd /run . start.sh # Startup hook @@ -15,10 +26,13 @@ cd /run . install.sh # Run installation . disk.sh # Initialize disks . display.sh # Initialize graphics +. prepare.sh # Prepare for launch . network.sh # Initialize network +. boot.sh # Configure boot . proc.sh # Initialize processor . serial.sh # Initialize serialport . power.sh # Configure shutdown +. balloon.sh # Initialize ballooning . config.sh # Configure arguments . finish.sh # Finish initialization diff --git a/src/finish.sh b/src/finish.sh index 3d96fd2..3162675 100644 --- a/src/finish.sh +++ b/src/finish.sh @@ -1,7 +1,12 @@ #!/usr/bin/env bash set -Eeuo pipefail +if [ -s "$QEMU_DIR/qemu.host" ] && [[ "$(<"$QEMU_DIR/qemu.host")" == "172.17."* ]]; then + warn "your container IP starts with 172.17.* which will cause conflicts when you install the Container Manager package inside DSM!" +fi + if enabled "$DEBUG"; then + echo printf "QEMU arguments:\n\n %s\n\n" "${ARGS// -/$'\n -'}" fi diff --git a/src/init.sh b/src/init.sh deleted file mode 100644 index eef5466..0000000 --- a/src/init.sh +++ /dev/null @@ -1,296 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -trap 'error "Status $? while: $BASH_COMMAND (line $LINENO/$BASH_LINENO)"' ERR -enabled "${TRACE:-}" && set -o functrace && trap 'echo "# $BASH_COMMAND" >&2' DEBUG - -[ ! -f "/run/entry.sh" ] && error "Script must be run inside the container!" && exit 11 -[ "$(id -u)" -ne "0" ] && error "Script must be executed with root privileges." && exit 12 - -# Docker environment variables - -: "${TZ:=""}" # System timezone -: "${KVM:="Y"}" # KVM acceleration -: "${DEBUG:="N"}" # Disable debugging mode -: "${COUNTRY:=""}" # Country code for mirror -: "${MACHINE:="q35"}" # Machine type selection -: "${ALLOCATE:=""}" # Preallocate diskspace -: "${ARGUMENTS:=""}" # Extra QEMU parameters -: "${CPU_CORES:="2"}" # Amount of CPU cores -: "${RAM_SIZE:="2G"}" # Maximum RAM amount -: "${RAM_CHECK:="Y"}" # Check available RAM -: "${DISK_SIZE:="16G"}" # Initial data disk size -: "${STORAGE:="/storage"}" # Storage folder location - -detectEngine() { - - if [ -f "/run/.containerenv" ]; then - ENGINE="${container:-}" - - if [[ "${ENGINE,,}" == *"podman"* ]]; then - ENGINE="Podman" - else - [ -z "$ENGINE" ] && ENGINE="Kubernetes" - fi - elif [ -f "/.dockerenv" ]; then - ENGINE="Docker" - fi - - return 0 -} - -detectRootless() { - - local uid_map - - # A full identity UID map indicates a rootful container; any remapping is - # treated as rootless even though the process itself runs as UID 0. - uid_map=$(awk '{$1=$1; print}' /proc/self/uid_map 2>/dev/null || true) - - if [[ "$uid_map" == "0 0 4294967295" ]]; then - ROOTLESS="N" - else - ROOTLESS="Y" - fi - - return 0 -} - -checkPrivileged() { - - local cap_bnd - local last_cap - # Get the capability bounding set - cap_bnd=$(grep '^CapBnd:' /proc/$$/status | awk '{print $2}') - cap_bnd=$(printf "%d" "0x${cap_bnd}") - - # Get the last capability number - last_cap=$(cat /proc/sys/kernel/cap_last_cap) - - # Calculate the maximum capability value - # Compare the bounding set with every capability supported by this kernel; - # checking only a few known capabilities would misclassify newer kernels. - local max_cap=$(((1 << (last_cap + 1)) - 1)) - - if [ "$cap_bnd" -eq "$max_cap" ]; then - PRIVILEGED="Y" - fi - - return 0 -} - -checkCores() { - - CPU_CORES=$(strip "$CPU_CORES") - [ -z "$CPU_CORES" ] && CPU_CORES=2 - [[ "${CPU_CORES,,}" == "max" ]] && CPU_CORES="$CORES" - [[ "${CPU_CORES,,}" == "half" ]] && CPU_CORES=$(( CORES / 2 )) - [ -z "${CPU_CORES##*[!0-9]*}" ] && error "Invalid amount of CPU_CORES: $CPU_CORES" && exit 15 - [ "$CPU_CORES" -lt "1" ] && CPU_CORES=1 - - if [ "$CPU_CORES" -gt "$CORES" ]; then - warn "The amount for CPU_CORES (${CPU_CORES}) exceeds the amount of logical cores available (${CORES}) and will be limited." - CPU_CORES="$CORES" - fi - - return 0 -} - -checkSockets() { - - local lscpu_out - lscpu_out=$(lscpu 2>/dev/null || true) - - if grep -qi "socket(s)" <<< "$lscpu_out"; then - SOCKETS=$(grep -m 1 -i 'socket(s)' <<< "$lscpu_out" | awk '{print $2}') - [ -z "${SOCKETS##*[!0-9]*}" ] && SOCKETS=1 - [ "$SOCKETS" -lt "1" ] && SOCKETS=1 - fi - - return 0 -} - -checkStorage() { - - # Check system - - QEMU_DIR="/run/shm" - - if [ ! -d "/dev/shm" ]; then - error "Directory /dev/shm not found!" && exit 14 - else - # Keep runtime sockets and PID files on shared memory even on images where - # /run/shm is absent but /dev/shm is available. - [ ! -d "$QEMU_DIR" ] && ln -s /dev/shm "$QEMU_DIR" - fi - - QEMU_PID="$QEMU_DIR/qemu.pid" - - # Check folder - - if [[ "${STORAGE,,}" != "/storage" ]]; then - if ! mkdir -p -- "$STORAGE"; then - error "Cannot create storage folder ($STORAGE)!" && exit 13 - fi - fi - - if [ ! -d "$STORAGE" ]; then - error "Storage folder ($STORAGE) not found!" && exit 13 - fi - - if [ ! -w "$STORAGE" ]; then - msg="Storage folder ($STORAGE) is not writeable!" - msg+=" If SELinux is active, you need to add the \":Z\" flag to the bind mount." - error "$msg" && exit 13 - fi - - return 0 -} - -checkHost() { - - # Check filesystem - if [[ "${FS,,}" == "ecryptfs" || "${FS,,}" == "tmpfs" ]]; then - DISK_IO="threads" - DISK_CACHE="writeback" - fi - - return 0 -} - -checkKvm() { - - # Check KVM support - - if [[ "${PLATFORM,,}" == "x64" ]]; then - TARGET="amd64" - else - TARGET="arm64" - fi - - if disabled "$KVM"; then - warn "KVM acceleration is disabled, this will cause the machine to run about 10 times slower!" - else - # KVM accelerates only matching host and guest instruction sets; cross- - # architecture execution must fall back to software emulation. - if [[ "${ARCH,,}" != "$TARGET" ]]; then - KVM="N" - warn "your CPU architecture is ${ARCH^^} and cannot provide KVM acceleration for ${PLATFORM^^} instructions, so the machine will run about 10 times slower." - fi - fi - - if ! disabled "$KVM"; then - - KVM_ERR="" - - if [ ! -e /dev/kvm ]; then - KVM_ERR="(/dev/kvm is missing)" - else - if ! sh -c 'echo -n > /dev/kvm' &> /dev/null; then - KVM_ERR="(/dev/kvm is unwriteable)" - else - if [[ "${PLATFORM,,}" == "x64" ]]; then - flags=$(sed -ne '/^flags/s/^.*: //p' /proc/cpuinfo) - if ! grep -qw "vmx\|svm" <<< "$flags"; then - KVM_ERR="(not enabled in BIOS)" - fi - if ! grep -qw "sse4_2" <<< "$flags"; then - error "Your CPU does not have the SSE4 instruction set that Virtual DSM requires!" - enabled "$DEBUG" || exit 88 - fi - fi - fi - fi - - if [ -n "$KVM_ERR" ]; then - KVM="N" - if [[ "$OSTYPE" =~ ^darwin ]]; then - warn "you are using macOS which has no KVM support, so the machine will run about 10 times slower." - else - kernel=$(uname -a) - case "${kernel,,}" in - *"microsoft"* ) - error "Please bind '/dev/kvm' as a volume in the optional container settings when using Docker Desktop." ;; - *"synology"* ) - error "Please make sure that Synology VMM (Virtual Machine Manager) is installed and that '/dev/kvm' is binded to this container." ;; - *) - error "KVM acceleration is not available $KVM_ERR, this will cause the machine to run about 10 times slower." - error "See the FAQ for possible causes, or disable acceleration by adding the \"KVM=N\" variable (not recommended)." ;; - esac - enabled "$DEBUG" || exit 88 - fi - fi - - fi - - return 0 -} - -# Sanitize variables -TZ=$(strip "$TZ") -STORAGE=$(strip "$STORAGE") -COUNTRY=$(strip "$COUNTRY") -MACHINE=$(strip "${MACHINE,,}") -DISK_SIZE=$(strip "$DISK_SIZE") - -# Helper variables -ROOTLESS="N" -PRIVILEGED="N" -ENGINE="Docker" -PROCESS="${APP,,}" -PROCESS="${PROCESS// /-}" - -detectEngine -detectRootless - -echo "❯ Starting $APP for $ENGINE v$( SPACE )); then - error "Not enough free space inside the container, have $SPACE_MB available but need at least 2 GB." && exit 93 - fi - rm -rf "$TMP" && mkdir -p "$TMP" -fi - -# Check free diskspace -ROOT_SPACE=536870912 -SPACE=$(df --output=avail -B 1 / | tail -n 1) -SPACE_MB=$(formatBytes "$SPACE" "down") -(( ROOT_SPACE > SPACE )) && error "Not enough free space inside the container, have $SPACE_MB available but need at least 500 MB." && exit 96 - -MIN_SPACE=15032385536 -SPACE=$(df --output=avail -B 1 "$STORAGE" | tail -n 1) -SPACE_GB=$(formatBytes "$SPACE") -(( MIN_SPACE > SPACE )) && error "Not enough free space for installation in $STORAGE, have $SPACE_GB available but need at least 14 GB." && exit 94 - -if [[ "$URL" == "file://"* ]]; then - MSG="Copying DSM" - ERR="Failed to copy ${URL:7}" - info "Install: Copying installation image..." -else - MSG="Downloading DSM" - ERR="Failed to download $URL" - info "Install: Downloading $BASE.pat..." -fi - -html "$MSG..." - -PAT="/$BASE.pat" -rm -f "$PAT" - -if [[ "$URL" == "file://"* ]]; then - - if [ ! -f "${URL:7}" ]; then - error "File '${URL:7}' does not exist!" && exit 65 + if [[ "${fs,,}" == "overlay"* && "${ENGINE,,}" == "docker" ]]; then + warn "the filesystem of $STORAGE is OverlayFS, this usually means it was binded to an invalid path!" fi - cp "${URL:7}" "$PAT" + if [[ "${fs,,}" == "fuse"* ]]; then + warn "the filesystem of $STORAGE is FUSE, this extra layer will negatively affect performance!" + fi -else + if [[ "${fs,,}" == "ecryptfs" || "${fs,,}" == "tmpfs" ]]; then + warn "the filesystem of $STORAGE is $fs, which does not support O_DIRECT mode, adjusting settings..." + fi - SIZE=0 - REASON="" - PROGRESS=() - OUTPUT="" - LOG=$(mktemp) + if [[ "${fs,,}" == "fat"* || "${fs,,}" == "vfat"* || "${fs,,}" == "msdos"* ]]; then + error "Unable to install on $fs filesystems, please use a different filesystem for /storage." + return 61 + fi - [[ "${URL,,}" == *"_72806.pat" ]] && SIZE=361010261 - [[ "${URL,,}" == *"_69057.pat" ]] && SIZE=363837333 - [[ "${URL,,}" == *"_42218.pat" ]] && SIZE=379637760 + return 0 +} + +checkDsmSpace() { + + local rootSpace=536870912 + local minSpace=15032385536 + local space available + + space=$(df --output=avail -B 1 / | tail -n 1) || return $? + available=$(formatBytes "$space" "down") || return $? + + if (( rootSpace > space )); then + error "Not enough free space inside the container, have $available available but need at least 500 MB." + return 96 + fi + + space=$(df --output=avail -B 1 "$STORAGE" | tail -n 1) || return $? + available=$(formatBytes "$space") || return $? + + if (( minSpace > space )); then + error "Not enough free space for installation in $STORAGE, have $available available but need at least 14 GB." + return 94 + fi + + return 0 +} + +downloadFile() { + + local url="$1" + local pat="$2" + local msg="$3" + local connections="${4:-1}" + + downloadToFile \ + "$url" \ + "$pat" \ + "$msg" \ + "0" \ + "$connections" \ + "Y" +} + +downloadPat() { + + local pat="$1" + local msg err + + if [[ "$URL" == "file://"* ]]; then + msg="Copying DSM" + err="Failed to copy ${URL:7}" + info "Install: Copying installation image..." + html "$msg..." || return $? + + rm -f "$pat" || return $? + + if [ ! -f "${URL:7}" ]; then + error "File '${URL:7}' does not exist!" + return 65 + fi + + cp "${URL:7}" "$pat" || return $? - # Use Wget's progress bar in a terminal and progress.sh in container logs. - if [ -t 1 ]; then - PROGRESS=( --show-progress --progress=bar:noscroll ) else - OUTPUT="log" + + msg="Downloading DSM" + err="Failed to download $URL" + info "Install: Downloading $BASE.pat..." + + downloadRetry \ + "$pat" \ + "$CONNECTIONS" \ + "5" \ + "$BASE.pat" \ + "0" \ + "$URL" \ + "$pat" \ + "$msg" || return 69 fi - /run/progress.sh "$PAT" "$SIZE" "$MSG ([P])..." "$OUTPUT" 52428800 & + if [ ! -s "$pat" ]; then + error "$err" + return 69 + fi - { - LC_ALL=C wget "$URL" -O "$PAT" --no-verbose --no-check-certificate \ - --timeout=30 --no-http-keep-alive "${PROGRESS[@]}" \ - --output-file="$LOG" - rc=$? - } || : + return 0 +} + +extractPat() { + + local pat="$1" + local tmp="$2" + local size="$3" + local msg="Extracting installation image..." + local rc + + info "Install: $msg" && html "$msg" || return $? + /run/progress.sh "$tmp" "$size" "$msg ([P])..." & + + # Newer PAT files are normal tar archives; older encrypted/proprietary forms + # require the bundled extractor as a compatibility fallback. + if { tar tf "$pat"; } >/dev/null 2>&1; then + + tar xpf "$pat" -C "$tmp/." || { + rc=$? + fKill "progress.sh" + return "$rc" + } + + else + + { (cd "$tmp" && python3 /run/extract.py -i "$pat" -d 2>/run/extract.log); rc=$?; } || : + + if (( rc != 0 )); then + fKill "progress.sh" + cat /run/extract.log + error "Failed to extract PAT file, reason $rc" + return 63 + fi + + fi fKill "progress.sh" + return 0 +} - if (( rc != 0 )); then - REASON=$(sed -n \ - -e 's/^wget: //p' \ - -e 's/^[0-9-]\{10\} [0-9:]\{8\} ERROR //p' \ - "$LOG" | tail -n 1) +createSystemImage() { + + local tmp="$1" + local fs="$2" + local msg="Preparing system partition..." + + info "Install: $msg" && html "$msg" || return $? + + # The PAT boot archive becomes the persistent QEMU boot disk after its + # companion system partition has been assembled. + DSM_BOOT=$(find "$tmp" -name "*.bin.zip" -print -quit) || return $? + + if [ -z "$DSM_BOOT" ]; then + error "The PAT file contains no boot image." + return 67 fi - rm -f "$LOG" - - if (( rc == 3 )); then - error "$ERR because the file could not be written (disk full?)." - exit 69 - elif (( rc != 0 )); then - if [ -n "$REASON" ]; then - error "$ERR: ${REASON%.}." - else - error "$ERR with exit status $rc." - fi - exit 69 + if [ ! -s "$DSM_BOOT" ]; then + error "The PAT boot image archive is empty." + return 67 fi -fi + 7z x -y -o"$tmp" "$DSM_BOOT" >/dev/null || return $? + DSM_BOOT="${DSM_BOOT%.zip}" -[ ! -s "$PAT" ] && error "$ERR" && exit 69 + SYSTEM="$STORAGE/$BASE.system.img" + rm -f "$SYSTEM" || return $? -SIZE=$(stat -c%s "$PAT") + # Check free diskspace + local systemSize=10738466816 + local space available -# Full Virtual DSM PAT files are substantially larger than update packs; -# reject undersized inputs before attempting destructive image preparation. -if ((SIZE<250000000)); then - error "The specified PAT file is probably an update pack as it's too small." && exit 62 -fi + space=$(df --output=avail -B 1 "$STORAGE" | tail -n 1) || return $? + available=$(formatBytes "$space") || return $? -MSG="Extracting installation image..." -info "Install: $MSG" && html "$MSG" - -/run/progress.sh "$TMP" "$SIZE" "$MSG ([P])..." & - -# Newer PAT files are normal tar archives; older encrypted/proprietary forms -# require the bundled extractor as a compatibility fallback. -if { tar tf "$PAT"; } >/dev/null 2>&1; then - - tar xpf "$PAT" -C "$TMP/." - -else - - { (cd "$TMP" && python3 /run/extract.py -i "$PAT" -d 2>/run/extract.log); rc=$?; } || : - - if (( rc != 0 )); then - fKill "progress.sh" - cat /run/extract.log - error "Failed to extract PAT file, reason $rc" && exit 63 + if (( systemSize > space )); then + error "Not enough free space in $STORAGE to create a 10 GB system disk, have only $available available." + return 97 fi -fi - -fKill "progress.sh" - -MSG="Preparing system partition..." -info "Install: $MSG" && html "$MSG" - -# The PAT boot archive becomes the persistent QEMU boot disk after its -# companion system partition has been assembled. -BOOT=$(find "$TMP" -name "*.bin.zip" -print -quit) -[ -z "$BOOT" ] && error "The PAT file contains no boot image." && exit 67 -[ ! -s "$BOOT" ] && error "The PAT boot image archive is empty." && exit 67 - -unzip -q -o "$BOOT" -d "$TMP" -BOOT="${BOOT%.zip}" - -SYSTEM="$STORAGE/$BASE.system.img" -rm -f "$SYSTEM" - -# Check free diskspace -SYSTEM_SIZE=10738466816 -SPACE=$(df --output=avail -B 1 "$STORAGE" | tail -n 1) -SPACE_MB=$(formatBytes "$SPACE") - -if (( SYSTEM_SIZE > SPACE )); then - error "Not enough free space in $STORAGE to create a 10 GB system disk, have only $SPACE_MB available." && exit 97 -fi - -if ! touch "$SYSTEM"; then - error "Could not create file $SYSTEM for the system disk." && exit 98 -fi - -setOwner "$SYSTEM" || warn "failed to set the owner for \"$SYSTEM\" !" - -if [[ "${FS,,}" == "btrfs" ]]; then - { chattr +C "$SYSTEM"; } || : - FA=$(lsattr "$SYSTEM") - if [[ "$FA" != *"C"* ]]; then - error "Failed to disable COW for system image $SYSTEM on ${FS^^} filesystem." + if ! touch "$SYSTEM"; then + error "Could not create file $SYSTEM for the system disk." + return 98 fi -fi -if ! fallocate -l "$SYSTEM_SIZE" "$SYSTEM" &>/dev/null; then - if ! fallocate -l -x "$SYSTEM_SIZE" "$SYSTEM"; then - if ! truncate -s "$SYSTEM_SIZE" "$SYSTEM"; then - rm -f "$SYSTEM" - error "Could not allocate file $SYSTEM for the system disk." && exit 98 + setOwner "$SYSTEM" || warn "failed to set the owner for \"$SYSTEM\" !" + + if [[ "${fs,,}" == "btrfs" ]]; then + { chattr +C "$SYSTEM"; } || : + local attrs + attrs=$(lsattr "$SYSTEM") || return $? + if [[ "$attrs" != *"C"* ]]; then + error "Failed to disable COW for system image $SYSTEM on ${fs^^} filesystem." fi fi -fi -# Recreate Synology's expected DOS partition layout inside the fixed 10 GiB -# system image before populating the ext4 root partition. -PART="$TMP/partition.fdisk" + if ! fallocate -l "$systemSize" "$SYSTEM" &>/dev/null; then + if ! fallocate -l -x "$systemSize" "$SYSTEM"; then + if ! truncate -s "$systemSize" "$SYSTEM"; then + rm -f "$SYSTEM" + error "Could not allocate file $SYSTEM for the system disk." + return 98 + fi + fi + fi -{ - echo "label: dos" - echo "label-id: 0x6f9ee2e9" - echo "device: $SYSTEM" - echo "unit: sectors" - echo "sector-size: 512" - echo "" - echo "${SYSTEM}1 : start= 2048, size= 16777216, type=83" - echo "${SYSTEM}2 : start= 16779264, size= 4194304, type=82" -} > "$PART" + # Recreate Synology's expected DOS partition layout inside the fixed 10 GiB + # system image before populating the ext4 root partition. + local part="$tmp/partition.fdisk" -sfdisk -q "$SYSTEM" < "$PART" + if ! { + echo "label: dos" + echo "label-id: 0x6f9ee2e9" + echo "device: $SYSTEM" + echo "unit: sectors" + echo "sector-size: 512" + echo "" + echo "${SYSTEM}1 : start= 2048, size= 16777216, type=83" + echo "${SYSTEM}2 : start= 16779264, size= 4194304, type=82" + } > "$part"; then + return 1 + fi -MOUNT="$TMP/system" -rm -rf "$MOUNT" + sfdisk -q "$SYSTEM" < "$part" || return $? -if ! makeDir "$MOUNT"; then - error "Failed to create directory \"$MOUNT\" !" && exit 93 -fi + local mount="$tmp/system" + rm -rf "$mount" || return $? -MSG="Extracting system partition..." -info "Install: $MSG" && html "$MSG" + if ! makeDir "$mount"; then + error "Failed to create directory \"$mount\" !" + return 93 + fi -HDA="$TMP/hda1" -IDB="$TMP/indexdb" -PKG="$TMP/packages" -HDP="$TMP/synohdpack_img" + return 0 +} -[ ! -s "$HDA.tgz" ] && error "The PAT file contains no OS image." && exit 64 -mv "$HDA.tgz" "$HDA.txz" +installSystemPartition() { -[ -d "$PKG" ] && mv "$PKG/" "$MOUNT/.SynoUpgradePackages/" -rm -f "$MOUNT/.SynoUpgradePackages/ActiveInsight-"* + local tmp="$1" + local mount="$tmp/system" + local msg="Extracting system partition..." -INDEX_DB="$MOUNT/usr/syno/synoman/indexdb" + info "Install: $msg" && html "$msg" || return $? -if [ -s "$IDB.txz" ]; then - mkdir -p "$INDEX_DB" -fi + local hda="$tmp/hda1" + local idb="$tmp/indexdb" + local pkg="$tmp/packages" + local hdp="$tmp/synohdpack_img" -LABEL="1.44.1-42218" -OFFSET="1048576" # 2048 * 512 -NUMBLOCKS="2097152" # (16777216 * 512) / 4096 -MSG="Installing system partition..." + if [ ! -s "$hda.tgz" ]; then + error "The PAT file contains no OS image." + return 64 + fi -# Build the ext4 filesystem directly from the extracted tree under fakeroot, -# preserving archive ownership without mounting a loop device. -fakeroot -- bash -c "set -Eeu;\ - [ -s $HDP.txz ] && tar xpfJ $HDP.txz --absolute-names -C $MOUNT/;\ - [ -s $IDB.txz ] && tar xpfJ $IDB.txz --absolute-names -C $INDEX_DB/;\ - tar xpfJ $HDA.txz --absolute-names --skip-old-files -C $MOUNT/;\ - printf '%b%s%b' '\E[1;34m❯ \E[1;36m' 'Install: $MSG' '\E[0m\n';\ - mke2fs -q -t ext4 -b 4096 -d $MOUNT/ -L $LABEL -F -E offset=$OFFSET $SYSTEM $NUMBLOCKS" + mv "$hda.tgz" "$hda.txz" || return $? -rm -rf "$MOUNT" -echo "$BASE" > "$STORAGE/dsm.ver" -setOwner "$STORAGE/dsm.ver" || warn "failed to set the owner for \"$STORAGE/dsm.ver\" !" + if [ -d "$pkg" ]; then + mv "$pkg/" "$mount/.SynoUpgradePackages/" || return $? + fi -# Do not keep a second copy when the source PAT already lives in storage; -# downloaded or externally mounted sources are cached for later reuse. -if [[ "$URL" == "file://$STORAGE/$BASE.pat" ]]; then - rm -f "$PAT" -else - mv -f "$PAT" "$STORAGE/$BASE.pat" -fi + rm -f "$mount/.SynoUpgradePackages/ActiveInsight-"* || return $? -if [ -f "$STORAGE/$BASE.pat" ]; then - setOwner "$STORAGE/$BASE.pat" || warn "failed to set the owner for \"$STORAGE/$BASE.pat\" !" -fi + local indexDb="$mount/usr/syno/synoman/indexdb" -mv -f "$BOOT" "$STORAGE/$BASE.boot.img" -setOwner "$STORAGE/$BASE.boot.img" || warn "failed to set the owner for \"$STORAGE/$BASE.boot.img\" !" + if [ -s "$idb.txz" ]; then + mkdir -p "$indexDb" || return $? + fi -rm -rf "$TMP" + local label="1.44.1-42218" + local offset="1048576" # 2048 * 512 + local numBlocks="2097152" # (16777216 * 512) / 4096 + local rc + msg="Installing system partition..." + # Build the ext4 filesystem directly from the extracted tree under fakeroot, + # preserving archive ownership without mounting a loop device. + if fakeroot -- bash -c "set -Eeu;\ + [ -s $hdp.txz ] && tar xpfJ $hdp.txz --absolute-names -C $mount/;\ + [ -s $idb.txz ] && tar xpfJ $idb.txz --absolute-names -C $indexDb/;\ + tar xpfJ $hda.txz --absolute-names --skip-old-files -C $mount/;\ + printf '%b%s%b' '\E[1;34m❯ \E[1;36m' 'Install: $msg' '\E[0m\n';\ + mke2fs -q -t ext4 -b 4096 -d $mount/ -L $label -F -E offset=$offset $SYSTEM $numBlocks"; then + : + else + rc=$? + return "$rc" + fi + + rm -rf "$mount" || return $? + return 0 +} + +checkSse42() { + + if disabled "$KVM" || [[ "${PLATFORM,,}" != "x64" ]]; then + return 0 + fi + + if ! hasFlag "sse4_2"; then + error "Your CPU does not have the SSE4.2 instruction set that Virtual DSM requires!" + enabled "$DEBUG" || return 88 + fi + + return 0 +} + +sanitizePatBase() { + + local source="$1" + local base + + base=$(basename "${source%%\?*}" .pat) || return 1 + printf -v base '%b' "${base//%/\\x}" || return 1 + base="${base//[!A-Za-z0-9._-]/_}" + + printf '%s' "$base" +} + +reservePorts() { + + local port ports="" + local hostPorts="${HOST_PORTS:-}" + + # Older configurations may reserve DSM's web ports for the container. + # They now need to be forwarded to the guest instead. + for port in ${hostPorts//,/ }; do + + case "${port,,}" in + 5000|5000/tcp|5001|5001/tcp) + continue ;; + esac + + ports+="${ports:+,}$port" + + done + + HOST_PORTS="$ports" + + # NAT can fall back to user-mode networking after this point, + # so always add the DSM web interface ports for non-DHCP networking. + USER_PORTS="${USER_PORTS:+$USER_PORTS,}5000/tcp,5001/tcp,80/tcp,443/tcp,445/tcp" + + return 0 +} + +prepareMac() { + + local file="$STORAGE/$PROCESS.mac" + local legacy="$STORAGE/dsm.mac" + local container mac="" + + # An explicitly configured MAC remains network.sh's responsibility. + [ -n "${MAC:-}" ] && return 0 + [ -s "$file" ] && return 0 + + # Preserve the address generated by older Virtual DSM releases. + if [ -s "$legacy" ]; then + mac=$(readFile "$legacy") || return $? + fi + + if [ -z "$mac" ]; then + container=$(containerID) || return $? + mac=$(echo "$container" | md5sum | + sed 's/^\(..\)\(..\)\(..\)\(..\)\(..\).*$/02:11:32:\3:\4:\5/') || return $? + fi + + if ! writeState "mac" "${mac^^}"; then + error "Failed to write MAC address to \"$file\" !" + return 28 + fi + + return 0 +} + +getCountry() { + + local url=$1 + local query=$2 + local json result + + { json=$(curl -m 5 -H "Accept: application/json" -sfk "$url"); local rc=$?; } || : + (( rc != 0 )) && return 0 + + { result=$(echo "$json" | jq -r "$query" 2> /dev/null); rc=$?; } || : + (( rc != 0 )) && return 0 + + [[ ${#result} -ne 2 ]] && return 0 + [[ "${result^^}" == "XX" ]] && return 0 + + COUNTRY="${result^^}" + + return 0 +} + +setCountry() { + + [[ "${TZ,,}" == "asia/harbin" ]] && COUNTRY="CN" + [[ "${TZ,,}" == "asia/beijing" ]] && COUNTRY="CN" + [[ "${TZ,,}" == "asia/urumqi" ]] && COUNTRY="CN" + [[ "${TZ,,}" == "asia/kashgar" ]] && COUNTRY="CN" + [[ "${TZ,,}" == "asia/shanghai" ]] && COUNTRY="CN" + [[ "${TZ,,}" == "asia/chongqing" ]] && COUNTRY="CN" + + # Country detection is best-effort and tries independent services in order; + # failure leaves mirror selection at its global default. + [ -z "$COUNTRY" ] && getCountry "https://api.ipapi.is" ".location.country_code" + [ -z "$COUNTRY" ] && getCountry "https://ifconfig.co/json" ".country_iso" + [ -z "$COUNTRY" ] && getCountry "https://api.ip2location.io" ".country_code" + [ -z "$COUNTRY" ] && getCountry "https://ipinfo.io/json" ".country" + [ -z "$COUNTRY" ] && getCountry "https://api.ipquery.io/?format=json" ".location.country_code" + [ -z "$COUNTRY" ] && getCountry "https://api.myip.com" ".cc" + + return 0 +} + +finishDsmInstall() { + + local pat="$1" + local tmp="$2" + + echo "$BASE" > "$STORAGE/dsm.ver" || return 1 + setOwner "$STORAGE/dsm.ver" || warn "failed to set the owner for \"$STORAGE/dsm.ver\" !" + + # Do not keep a second copy when the source PAT already lives in storage; + # downloaded or externally mounted sources are cached for later reuse. + if [[ "$URL" == "file://$STORAGE/$BASE.pat" ]]; then + rm -f "$pat" || return $? + else + mv -f "$pat" "$STORAGE/$BASE.pat" || return $? + fi + + if [ -f "$STORAGE/$BASE.pat" ]; then + setOwner "$STORAGE/$BASE.pat" || warn "failed to set the owner for \"$STORAGE/$BASE.pat\" !" + fi + + mv -f "$DSM_BOOT" "$STORAGE/$BASE.boot.img" || return $? + setOwner "$STORAGE/$BASE.boot.img" || warn "failed to set the owner for \"$STORAGE/$BASE.boot.img\" !" + + rm -rf "$tmp" || return $? + return 0 +} + +installDSM() { + + rm -rf /tmp/dsm || return $? + rm -f "$QEMU_DIR/dsm.url" || return $? + + checkSse42 || return $? + reservePorts || return $? + prepareMac || return $? + + local patName="boot.pat" + local patDir patFile + + # Persist the exact PAT base name so future starts reopen the matching boot, + # system, and cached installation files. + if [ -f "$STORAGE/dsm.ver" ]; then + BASE=$(<"$STORAGE/dsm.ver") || return $? + BASE="${BASE//[![:print:]]/}" + [ -z "$BASE" ] && BASE="DSM_VirtualDSM_69057" + else + # Fallback for old installs + BASE="DSM_VirtualDSM_42962" + fi + + patDir=$(find / -maxdepth 1 -type d -iname "$patName" -print -quit) || return $? + + if [ ! -d "$patDir" ]; then + patDir=$(find "$STORAGE" -maxdepth 1 -type d -iname "$patName" -print -quit) || return $? + fi + + # A boot.pat directory bind represents already extracted boot and system + # images and therefore takes precedence over PAT file or URL discovery. + if [ -d "$patDir" ]; then + BASE="DSM_VirtualDSM" + URL="file://$patDir" + + if [[ ! -s "$STORAGE/$BASE.boot.img" || ! -s "$STORAGE/$BASE.system.img" ]]; then + error "The bind $patDir maps to a file that does not exist!" + return 65 + fi + fi + + patFile=$(find / -maxdepth 1 -type f -iname "$patName" -print -quit) || return $? + + if [ ! -s "$patFile" ]; then + patFile=$(find "$STORAGE" -maxdepth 1 -type f -iname "$patName" -print -quit) || return $? + fi + + if [ -s "$patFile" ]; then + BASE="DSM_VirtualDSM" + URL="file://$patFile" + fi + + URL=$(strip "$URL") || return $? + + # Derive a filesystem-safe identity from the URL only when no local boot.pat + # source was supplied; preserve an existing system image identity if present. + if [ -n "$URL" ] && [ ! -s "$patFile" ] && [ ! -d "$patDir" ]; then + BASE=$(basename "$URL" .pat) || return $? + + if [ ! -s "$STORAGE/$BASE.system.img" ]; then + BASE=$(sanitizePatBase "$URL") || return $? + fi + + if [[ "${URL,,}" != "http"* && "${URL,,}" != "file:"* ]]; then + if [ ! -s "$STORAGE/$BASE.pat" ]; then + error "Invalid URL: $URL" + return 65 + fi + + URL="file://$STORAGE/$BASE.pat" + fi + fi + + # A complete matching image pair is the installation marker; the cached PAT + # itself is optional after installation. + if [[ -s "$STORAGE/$BASE.boot.img" && -s "$STORAGE/$BASE.system.img" ]]; then + return 0 # Previous installation found + fi + + html "Please wait while Virtual DSM is being installed..." || return $? + + local mirror="" + local chinaMirror="https://cndl.synology.cn/download/DSM" + local globalMirror="https://global.synologydownload.com/download/DSM" + + [[ "${URL,,}" == *"cndl.synology"* ]] && mirror="$chinaMirror" + [[ "${URL,,}" == *"global.synology"* ]] && mirror="$globalMirror" + + # Honor an explicitly selected Synology mirror first, otherwise choose the + # China or global endpoint from the detected country. + if [ -z "$mirror" ]; then + if [ -z "$COUNTRY" ]; then + setCountry || return $? + fi + + [ -z "$COUNTRY" ] && info "Warning: could not detect country to select mirror!" + [[ "${COUNTRY^^}" == "CN" ]] && mirror="$chinaMirror" || mirror="$globalMirror" + fi + + if [ -z "$URL" ]; then + URL="$mirror/release/7.2.2/72806/DSM_VirtualDSM_72806.pat" + fi + + if [ ! -s "$patFile" ]; then + BASE=$(sanitizePatBase "$URL") || return $? + fi + + if [[ "$URL" != "file://$STORAGE/$BASE.pat" ]]; then + rm -f "$STORAGE/$BASE.pat" || return $? + fi + + rm -f "$STORAGE/$BASE.agent" || return $? + rm -f "$STORAGE/$BASE.boot.img" || return $? + rm -f "$STORAGE/$BASE.system.img" || return $? + + # Check filesystem + local fs + fs=$(stat -f -c %T "$STORAGE") || return $? + checkDsmFilesystem "$fs" || return $? + + local tmp + + # Extract beside storage on Unix filesystems to avoid container-space limits; + # use /tmp for filesystems that cannot safely host the installer workspace. + if [[ "${fs,,}" != "exfat"* && "${fs,,}" != "ntfs"* && "${fs,,}" != "unknown"* ]]; then + tmp="$STORAGE/tmp" + rm -rf "$tmp" || return $? + + if ! makeDir "$tmp"; then + error "Failed to create directory \"$tmp\" !" + return 93 + fi + else + tmp="/tmp/dsm" + local tmpSpace=2147483648 + local space available + + space=$(df --output=avail -B 1 /tmp | tail -n 1) || return $? + available=$(formatBytes "$space") || return $? + + if (( tmpSpace > space )); then + error "Not enough free space inside the container, have $available available but need at least 2 GB." + return 93 + fi + + rm -rf "$tmp" || return $? + mkdir -p "$tmp" || return $? + fi + + # Check free diskspace + checkDsmSpace || return $? + + local pat="/$BASE.pat" + downloadPat "$pat" || return $? + + local size + size=$(stat -c%s "$pat") || return $? + + # Full Virtual DSM PAT files are substantially larger than update packs; + # reject undersized inputs before attempting destructive image preparation. + if (( size < 250000000 )); then + error "The specified PAT file is probably an update pack as it's too small." + return 62 + fi + + extractPat "$pat" "$tmp" "$size" || return $? + createSystemImage "$tmp" "$fs" || return $? + installSystemPartition "$tmp" || return $? + finishDsmInstall "$pat" "$tmp" || return $? + + return 0 +} + +installDSM || exit $? return 0 diff --git a/src/memory.sh b/src/memory.sh deleted file mode 100644 index 58f09a3..0000000 --- a/src/memory.sh +++ /dev/null @@ -1,235 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -normalizeMemory() { - - local wanted - - RAM_SPARE=500000000 - RAM_MINIMUM="${RAM_MINIMUM:-1073741824}" - - RAM_MINIMUM=$(strip "$RAM_MINIMUM") - RAM_MINIMUM="${RAM_MINIMUM// /}" - RAM_MINIMUM=$(echo "${RAM_MINIMUM^^}" | sed 's/MB/M/g;s/GB/G/g;s/TB/T/g') - numfmt --from=iec "$RAM_MINIMUM" &>/dev/null || { - error "Invalid RAM_MINIMUM: $RAM_MINIMUM" - exit 16 - } - RAM_MINIMUM=$(numfmt --from=iec "$RAM_MINIMUM") - - RAM_SIZE=$(strip "$RAM_SIZE") - RAM_SIZE="${RAM_SIZE// /}" - [ -z "$RAM_SIZE" ] && RAM_SIZE="2G" - - if [[ "${RAM_SIZE,,}" != "max" && "${RAM_SIZE,,}" != "half" ]]; then - - # Bare values below 130 are interpreted as GiB for convenience; larger bare - # values are treated as MiB to preserve historical configurations. - if [ -z "${RAM_SIZE//[0-9. ]}" ]; then - [ "${RAM_SIZE%%.*}" -lt "130" ] && RAM_SIZE="${RAM_SIZE}G" || RAM_SIZE="${RAM_SIZE}M" - fi - - RAM_SIZE=$(echo "${RAM_SIZE^^}" | sed 's/MB/M/g;s/GB/G/g;s/TB/T/g') - numfmt --from=iec "$RAM_SIZE" &>/dev/null || { - error "Invalid RAM_SIZE: $RAM_SIZE" - exit 16 - } - - wanted=$(numfmt --from=iec "$RAM_SIZE") - - if [ "$wanted" -lt "$RAM_MINIMUM" ]; then - error "$(app) requires at least $(formatBytes "$RAM_MINIMUM") of RAM, but RAM_SIZE is set to $(formatBytes "$wanted")." - exit 16 - fi - - # QEMU requires a whole-number memory value, so convert decimal sizes to MiB. - if [[ "$RAM_SIZE" == *.* ]]; then - RAM_SIZE="$(( wanted / 1048576 ))M" - fi - - fi - - return 0 -} - -checkConfiguredMemory() { - - local final="$1" - - if disabled "$RAM_CHECK" || [[ "${RAM_SIZE,,}" == "max" || "${RAM_SIZE,,}" == "half" ]]; then - return 0 - fi - - local wanted avail_mem - wanted=$(numfmt --from=iec "$RAM_SIZE") - avail_mem=$(formatBytes "$RAM_AVAIL") - - if (( (wanted + RAM_SPARE) > RAM_AVAIL )); then - - local msg="Your configured RAM_SIZE of ${RAM_SIZE/G/ GB} is too high for the $avail_mem of free memory available," - - # ZFS ARC can release cached memory under pressure, so this free-memory - # heuristic remains informational instead of rewriting RAM_SIZE. - if [[ "${FS,,}" == "zfs" ]]; then - - enabled "$final" && info "$msg but since ZFS is active this will be ignored." - - else - - RAM_SIZE="max" - RAM_WARNING="$msg it will automatically be adjusted to a lower amount." - - fi - - else - - if (( (wanted + (RAM_SPARE * 3)) > RAM_AVAIL )); then - - local msg="your configured RAM_SIZE of ${RAM_SIZE/G/ GB} is very close to the $avail_mem of free memory available," - - if [[ "${FS,,}" == "zfs" ]]; then - enabled "$final" && info "$msg but since ZFS is active this will be ignored." - else - enabled "$final" && warn "$msg please consider a lower amount." - fi - - fi - - fi - - return 0 -} - -configureHalfMemory() { - - if [[ "${RAM_SIZE,,}" != "half" ]]; then - return 0 - fi - - if (( (RAM_AVAIL / 2) > RAM_SPARE )); then - - local wanted=$(( RAM_AVAIL / 2 )) - - # Divide by one byte more than a MiB to round down - local target=$(( wanted / 1048577 )) - RAM_SIZE="${target}M" - RAM_ALLOCATION="$wanted" - - else - - RAM_SIZE="max" - - fi - - return 0 -} - -configureMaxMemory() { - - if [[ "${RAM_SIZE,,}" != "max" ]]; then - return 0 - fi - - # max keeps a host reserve when possible, but on very small systems falls back - # to half the available memory to avoid starving the container. - if (( RAM_AVAIL < (RAM_SPARE * 2) )); then - - local wanted=$(( RAM_AVAIL / 2 )) - - else - - local wanted=$(( RAM_AVAIL - (RAM_SPARE * 3) )) - - if (( wanted < (RAM_SPARE * 6) )); then - wanted=$(( RAM_AVAIL - RAM_SPARE )) - fi - - fi - - # Divide by one byte more than a MiB to round down - local target=$(( wanted / 1048577 )) - RAM_SIZE="${target}M" - RAM_ALLOCATION="$wanted" - - return 0 -} - -showMemoryLimitHint() { - - local kernel - kernel=$(uname -r) - - if [[ "${kernel,,}" == *-wsl2* ]]; then - echo - info "Docker Desktop (WSL2) is detected, follow these instructions:" - info "" - info "Increase the memory limit in \"%UserProfile%\\.wslconfig\" by setting \"memory=\" under \"[wsl2]\"." - info "Then run \"wsl --shutdown\" in PowerShell and restart Docker Desktop for the new limit to take effect." - echo - fi - - return 0 -} - -checkMinimumMemory() { - - local wanted - wanted=$(numfmt --from=iec "$RAM_SIZE") - - if [ "$wanted" -lt "$RAM_MINIMUM" ]; then - - error "$(app) requires at least $(formatBytes "$RAM_MINIMUM") of RAM, but only $(formatBytes "$wanted") can be allocated." - - showMemoryLimitHint - - exit 16 - fi - - return 0 -} - -checkMemoryAllocation() { - - local final="${1:-N}" - local configured - - normalizeMemory - configured="$RAM_SIZE" - - RAM_WARNING="" - RAM_ALLOCATION="" - - getMemoryInfo - checkConfiguredMemory "$final" - - configureHalfMemory - configureMaxMemory - checkMinimumMemory - - if enabled "$final"; then - [ -n "$RAM_WARNING" ] && warn "$RAM_WARNING" - [ -n "$RAM_ALLOCATION" ] && info "Allocated $(formatBytes "$RAM_ALLOCATION") of RAM for $(app)." - else - RAM_SIZE="$configured" - fi - - return 0 -} - -checkMemoryRequirement() { - - checkMemoryAllocation "N" - - return 0 -} - -finalizeMemory() { - - checkMemoryAllocation "Y" - - return 0 -} - -checkMemoryRequirement - -return 0 diff --git a/src/network.sh b/src/network.sh deleted file mode 100644 index d178449..0000000 --- a/src/network.sh +++ /dev/null @@ -1,2358 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -# Docker environment variables - -: "${DHCP:="N"}" -: "${NETWORK:="Y"}" -: "${HOST_PORTS:=""}" -: "${USER_PORTS:=""}" -: "${ADAPTER:="virtio-net-pci"}" - -: "${IP:="${VM_NET_IP:-}"}" -: "${DEV:="${VM_NET_DEV:-}"}" -: "${MTU:="${VM_NET_MTU:-}"}" -: "${TAP:="${VM_NET_TAP:-dsm}"}" -: "${HOST:="${VM_NET_HOST:-$APP}"}" -: "${MAC:="${VM_NET_MAC:-${MAC:-}}"}" -: "${BRIDGE:="${VM_NET_BRIDGE:-docker}"}" -: "${MASK:="${VM_NET_MASK:-255.255.255.0}"}" - -: "${PASST:="/run/passt"}" -: "${PASST_OPTS:=""}" -: "${PASST_DEBUG:=""}" -: "${PASST_PID:="/var/run/passt.pid"}" -: "${PASST_SOCKET:="/tmp/passt.socket"}" - -: "${DNSMASQ_OPTS:=""}" -: "${DNSMASQ_DEBUG:=""}" -: "${DNSMASQ:="/usr/sbin/dnsmasq"}" -: "${DNSMASQ_PID:="/var/run/dnsmasq.pid"}" - -# Sanitize variables -IP=$(strip "$IP") -DEV=$(strip "$DEV") -MTU=$(strip "$MTU") -TAP=$(strip "$TAP") -MAC=$(strip "$MAC") -HOST=$(strip "$HOST") -MASK=$(strip "$MASK") -BRIDGE=$(strip "$BRIDGE") -ADAPTER=$(strip "$ADAPTER") -NETWORK=$(strip "$NETWORK") -HOST_PORTS=$(strip "$HOST_PORTS") -USER_PORTS=$(strip "$USER_PORTS") - -ADD_ERR="Please add the following setting to your container:" - -# ###################################### -# Generic helpers -# ###################################### - -isNAT() { - - case "${NETWORK,,}" in - "nat" | "tap" | "tun" | "tuntap" | "y" | "" ) - return 0 ;; - *) - return 1 ;; - esac -} - -isUserMode() { - - case "${NETWORK,,}" in - "passt" | "slirp" | "user"* ) - return 0 ;; - *) - return 1 ;; - esac -} - -getMTU() { - - local dev="$1" - - if [ -r "/sys/class/net/$dev/mtu" ]; then - cat "/sys/class/net/$dev/mtu" - else - echo "0" - fi - - return 0 -} - -minMTU() { - - local mtu min="" - - for mtu in "$@"; do - [[ -z "$mtu" || "$mtu" == "0" ]] && continue - - if [[ -z "$min" || "$mtu" -lt "$min" ]]; then - min="$mtu" - fi - done - - echo "${min:-0}" - return 0 -} - -setMTU() { - - local dev="$1" - local mtu="$2" - - # MTU 0 means "do not set"; MTU 1500 is the normal default and does not need setting. - [[ "$mtu" == "0" || "$mtu" == "1500" ]] && return 0 - - if ! ip link set dev "$dev" mtu "$mtu"; then - warn "failed to set MTU size of $dev to $mtu." - fi - - return 0 -} - -gatewayMAC() { - - local mac="$1" - - echo "$mac" | md5sum | sed 's/^\(..\)\(..\)\(..\)\(..\)\(..\).*$/02:\1:\2:\3:\4:\5/' -} - -maskToCIDR() { - - local mask="$1" - local prefix - - if ! command -v ipcalc > /dev/null 2>&1; then - error "Required command 'ipcalc' is not installed!" - return 1 - fi - - prefix=$(ipcalc -n -b "0.0.0.0/$mask" 2>/dev/null | awk ' - /^Netmask:/ { - for (i = 1; i <= NF; i++) { - if ($i == "=") { - print $(i + 1) - exit - } - } - } - ') - - if [[ ! "$prefix" =~ ^[0-9]+$ ]] || (( prefix < 0 || prefix > 32 )); then - error "Invalid MASK: '$mask'" - return 1 - fi - - echo "$prefix" - return 0 -} - -networkCIDR() { - - local ip="$1" - local network - - network=$(ipcalc -n -b "$ip/$MASK" 2>/dev/null | awk ' - /^Network:/ { - print $2 - exit - } - ') - - if [[ ! "$network" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/[0-9]+$ ]]; then - error "Failed to calculate network address from IP '$ip' and netmask '$MASK'." - return 1 - fi - - echo "$network" - return 0 -} - -upstreamIP() { - - local subnet="$1" - local guest="$2" - local gateway="$3" - local broadcast candidate last - - broadcast=$(ipcalc -n -b "$subnet" 2>/dev/null | awk ' - /^Broadcast:/ { - print $2 - exit - } - ') - - if [[ ! "$broadcast" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then - return 1 - fi - - last="${broadcast##*.}" - - for (( last--; last>=2; last-- )); do - candidate="${broadcast%.*}.$last" - [[ "$candidate" == "$guest" || "$candidate" == "$gateway" ]] && continue - - echo "$candidate" - return 0 - done - - return 1 -} - -detectInterface() { - - if [ -n "$DEV" ]; then - return 0 - fi - - # Prefer the last attached Kubernetes network - [ -d "/sys/class/net/net0" ] && DEV="net0" - [ -d "/sys/class/net/net1" ] && DEV="net1" - [ -d "/sys/class/net/net2" ] && DEV="net2" - [ -d "/sys/class/net/net3" ] && DEV="net3" - - # Automatically detect the default network interface - [ -z "$DEV" ] && DEV=$(awk '$2 == 00000000 { print $1; exit }' /proc/net/route) - [ -z "$DEV" ] && DEV="eth0" - - return 0 -} - -formatAddress() { - - local ip="${1:-}" - local prefix="${2:-}" - local result="$ip" - - [ -z "$result" ] && return 1 - - if [ -n "$prefix" ] && [[ "$prefix" != "24" ]]; then - result+="/$prefix" - fi - - echo "$result" - return 0 -} - -defaultGateway() { - - ip -4 route list default dev "$1" 2>/dev/null | - awk '$1 == "default" { for (i = 1; i < NF; i++) if ($i == "via") { print $(i + 1); exit } }' || : - - return 0 -} - -detectAddresses() { - - GATEWAY=$(defaultGateway "$DEV") - { UPLINK=$(ip address show dev "$DEV" | grep inet | awk '/inet / { print $2 }' | cut -f1 -d/ | head -n 1); } 2>/dev/null || : - - IP6="" - - if [ -f /proc/net/if_inet6 ] && [[ "$(cat /proc/sys/net/ipv6/conf/all/disable_ipv6 2>/dev/null)" != "1" ]]; then - { IP6=$(ip -6 addr show dev "$DEV" scope global up); local rc=$?; } 2>/dev/null || : - (( rc != 0 )) && IP6="" - [ -n "$IP6" ] && IP6=$(echo "$IP6" | sed -e's/^.*inet6 \([^ ]*\)\/.*$/\1/;t;d' | head -n 1) - fi - - return 0 -} - -detectAdapter() { - - local result - - NIC="" - BUS="" - - result=$(ethtool -i "$DEV" 2>/dev/null || :) - - NIC=$(awk -F':[[:space:]]*' ' - tolower($1) == "driver" { - print $2 - exit - } - ' <<< "$result") - - BUS=$(awk -F':[[:space:]]*' ' - tolower($1) == "bus-info" { - print $2 - exit - } - ' <<< "$result") - - return 0 -} - -containerID() { - - local id - - id=$(hostname -s 2>/dev/null || true) - - if [ -z "$id" ] && [ -s /etc/machine-id ]; then - id=$(< /etc/machine-id) - fi - - if [ -z "$id" ] && [ -r /proc/sys/kernel/random/boot_id ]; then - id=$(< /proc/sys/kernel/random/boot_id) - fi - - [ -z "$id" ] && id="unknown" - - echo "$id" - return 0 -} - -canBindPrivilegedPort() { - - local port="$1" - local proto="${2:-tcp}" - local start="1024" - local rc=1 - - [ -r /proc/sys/net/ipv4/ip_unprivileged_port_start ] && - start=$(< /proc/sys/net/ipv4/ip_unprivileged_port_start) - - (( port >= start )) && return 0 - - if [[ "$proto" == "udp" ]]; then - { timeout 0.1 nc -4 -n -d -u -l 127.0.0.1 "$port" > /dev/null 2>&1; rc=$?; } || : - else - { timeout 0.1 nc -4 -n -d -l 127.0.0.1 "$port" > /dev/null 2>&1; rc=$?; } || : - fi - - (( rc == 124 )) -} - -disableIPv6() { - - local dev="$1" - - [ -d "/proc/sys/net/ipv6/conf/$dev" ] || return 0 - - # Best-effort only: Docker/rootless/container sysctl writes can fail. - sysctl -w "net.ipv6.conf.$dev.disable_ipv6=1" > /dev/null 2>&1 || : - sysctl -w "net.ipv6.conf.$dev.accept_ra=0" > /dev/null 2>&1 || : - - return 0 -} - -subnetInUse() { - - local subnet="$1" - local broader narrower routes - - if ! broader=$(ip -4 route show table all match "$subnet" 2>/dev/null); then - error "Failed to inspect existing routes for subnet $subnet." - return 2 - fi - - if ! narrower=$(ip -4 route show table all root "$subnet" 2>/dev/null); then - error "Failed to inspect existing routes for subnet $subnet." - return 2 - fi - - routes=$( - printf '%s\n%s\n' "$broader" "$narrower" | - grep -Ev '(^|[[:space:]])default([[:space:]]|$)' | - sort -u || true - ) - - [ -n "$routes" ] -} - -guestIP() { - - local ip="$1" - local min="${2:-2}" - local last="${ip##*.}" - - if [[ ! "$last" =~ ^[0-9]+$ ]] || (( last < min || last > 254 )); then - ip="${ip%.*}.$min" - fi - - echo "$ip" - return 0 -} - -natGuestIP() { - - local ip="$1" - local guest subnet second third - - third=$(cut -d. -f3 <<< "$ip") - - if [[ "$ip" == "172.30."* ]]; then - local start="31" - else - local start="30" - fi - - # Scan adjacent 172.30/31 through 172.254 subnets to avoid Docker routes - # while retaining the original third octet and guest host number. - for (( second=start; second<=254; second++ )); do - guest=$(guestIP "172.$second.$third.0" 2) - subnet=$(networkCIDR "$guest") || return 1 - - if subnetInUse "$subnet"; then - continue - else - local rc=$? - (( rc == 1 )) || return 1 - fi - - echo "$guest" - return 0 - done - - for (( second=30; second /run/shm/qemu.gw; then - error "Failed to write gateway file." - return 1 - fi - - enabled "${DNSMASQ_DISABLE:-}" && return 0 - enabled "$DEBUG" && echo "Starting dnsmasq daemon..." - - if readPidFile pid "$DNSMASQ_PID"; then - pKill "$pid" - fi - - rm -f "$DNSMASQ_PID" - - if isNAT; then - - # Create lease file for faster resolve - echo "0 $mac $ip $host 01:$mac" > /var/lib/misc/dnsmasq.leases || : - chmod 644 /var/lib/misc/dnsmasq.leases || : - - # dnsmasq configuration: - arguments+=" --dhcp-authoritative" - - # Set DHCP range and host - arguments+=" --dhcp-range=$ip,$ip" - arguments+=" --dhcp-host=$mac,,$ip,$host,1h" - - # Set DNS server and gateway - arguments+=" --dhcp-option=option:netmask,$mask" - arguments+=" --dhcp-option=option:router,$gateway" - arguments+=" --dhcp-option=option:dns-server,$gateway" - - # Set MTU through DHCP option 26 - if [[ "$GUEST_MTU" != "0" && "$GUEST_MTU" != "1500" ]]; then - arguments+=" --dhcp-option=option:mtu,$GUEST_MTU" - fi - - fi - - # Set interfaces - arguments+=" --interface=$fa" - arguments+=" --bind-interfaces" - - # Workaround NET_RAW capability - arguments+=" --no-ping" - - # Add DNS entry for container - arguments+=" --address=/host.lan/$gateway" - - # Add DNS entry for the upstream gateway. - if isNAT && [ -n "$upstream" ]; then - arguments+=" --address=/system.lan/$upstream" - fi - - # Avoid returning IPv6 records when the active network mode is IPv4-only. - if isNAT || [ -z "$IP6" ]; then - arguments+=" --filter-AAAA" - fi - - # Set local dns resolver to dnsmasq when needed - [ -f /etc/resolv.dnsmasq ] && arguments+=" --resolv-file=/etc/resolv.dnsmasq" - - # Set pid file - arguments+=" --pid-file=$DNSMASQ_PID" - - # Enable logging to file - local log="/var/log/dnsmasq.log" - rm -f "$log" - arguments+=" --log-facility=$log" - - arguments=$(echo "$arguments" | sed 's/\t/ /g' | tr -s ' ' | sed 's/^ *//') - enabled "$DEBUG" && printf "Dnsmasq arguments:\n\n %s\n\n" "${arguments// -/$'\n -'}" - - { $DNSMASQ ${arguments:+ $arguments}; local rc=$?; } || : - - if (( rc != 0 )); then - - local msg="Failed to start Dnsmasq, reason: $rc" - - if [[ "${NETWORK,,}" == "slirp" || "${NETWORK,,}" == "passt" ]] || ! enabled "$ROOTLESS" || enabled "$DEBUG"; then - [ -f "$log" ] && [ -s "$log" ] && cat "$log" - error "$msg" - fi - - return 1 - fi - - if enabled "$DNSMASQ_DEBUG"; then - tail -fn +0 "$log" --pid=$$ & - fi - - return 0 -} - -normalizePorts() { - - local list="$1" - local mode="${2:-tcp}" - local port num - local ports="" - - for port in ${list//,/ }; do - - [ -z "$port" ] && continue - - case "$mode" in - "tcp" ) - [[ "$port" == *"/udp" ]] && continue - num="${port%/tcp}" - [ -n "$num" ] && ports+="$num," - ;; - "all" ) - if [[ "$port" == *"/udp" ]]; then - num="${port%/udp}" - [ -n "$num" ] && ports+="$num/udp," - else - num="${port%/tcp}" - [ -n "$num" ] && ports+="$num/tcp," - fi - ;; - *) - return 1 - ;; - esac - - done - - # Remove duplicates - echo "${ports//,,/,}," | awk 'BEGIN{RS=ORS=","} !seen[$0]++' | sed 's/,*$//g' - - return 0 -} - -getReservedPorts() { - - local list="" - local mode="${1:-tcp}" - - # Reserve the DNS port while the internal dnsmasq resolver is active. - if ! enabled "${DNSMASQ_DISABLE:-}" && ! isNAT; then - list+="53/tcp,53/udp," - fi - - normalizePorts "$list" "$mode" - return $? -} - -getCustomHostPorts() { - - local mode="${1:-tcp}" - local reserved user port - local ports="" - - reserved=$(getReservedPorts "all") - user=$(normalizePorts "$HOST_PORTS" "all") - - for port in ${user//,/ }; do - [[ ",$reserved," == *",$port,"* ]] && continue - ports+="$port," - done - - normalizePorts "$ports" "$mode" - return $? -} - -getHostPorts() { - - local mode="${1:-tcp}" - local reserved custom - - # Merge internal reservations with user-defined host ports without mutating HOST_PORTS. - # User entries already covered by an internal reservation are silently ignored. - reserved=$(getReservedPorts "all") - custom=$(getCustomHostPorts "all") - normalizePorts "$reserved,$custom" "$mode" - return $? -} - -getUserPorts() { - - # User-mode networking forwards DSM management and SSH ports by default; - # internal container reservations and HOST_PORTS are removed below. - local defaults="22/tcp,5000/tcp,5001/tcp" - local list="$defaults,${USER_PORTS// /}," - - local ports="" - local userport hostport exclude reserved - - reserved=$(getReservedPorts "all") - exclude=$(getHostPorts "all") - - for userport in ${list//,/ }; do - - local proto="tcp" - local num="$userport" - - if [[ "$userport" == *"/udp" ]]; then - proto="udp" - num="${userport%/udp}" - elif [[ "$userport" == *"/tcp" ]]; then - proto="tcp" - num="${userport%/tcp}" - fi - - [ -z "$num" ] && continue - - for hostport in ${exclude//,/ }; do - - if [[ "$num/$proto" == "$hostport" ]]; then - - if [[ ",$reserved," == *",$hostport,"* ]]; then - warn "Could not assign port $hostport to \"USER_PORTS\" because it is reserved by the container!" - elif [[ "$hostport" != "${WEB_PORT:-}/tcp" ]]; then - warn "Could not assign port $hostport to \"USER_PORTS\" because it is already in \"HOST_PORTS\"!" - fi - - num="" - break - fi - - done - - [ -z "$num" ] && continue - - if ! canBindPrivilegedPort "$num" "$proto"; then - warn "Could not assign port $num/$proto to \"USER_PORTS\" because it cannot be bound by the current user!" - continue - fi - - ports+="$num/$proto," - done - - # Remove duplicates - echo "${ports//,,/,}," | awk 'BEGIN{RS=ORS=","} !seen[$0]++' | sed 's/,*$//g' - - return 0 -} - -getSlirp() { - - local ip="$1" - local args="" list - - list=$(getUserPorts) - - for port in ${list//,/ }; do - - local proto="tcp" - local num="${port%/tcp}" - [ -z "$num" ] && continue - - if [[ "$port" == *"/udp" ]]; then - proto="udp" - num="${port%/udp}" - fi - - args+="hostfwd=$proto::$num-$ip:$num," - done - - echo "$args" | sed 's/,*$//g' - return 0 -} - -getPasst() { - - local list port - local tcp="" udp="" args="" - - list=$(getUserPorts) - - for port in ${list//,/ }; do - - [ -z "$port" ] && continue - - if [[ "$port" == *"/udp" ]]; then - - local num="${port%/udp}" - [ -n "$num" ] && udp+="$num," - - elif [[ "$port" == *"/tcp" ]]; then - - local num="${port%/tcp}" - [ -n "$num" ] && tcp+="$num," - - else - - tcp+="$port," - - fi - - done - - tcp="${tcp%,}" - udp="${udp%,}" - - [ -n "$tcp" ] && args+=" -t $tcp" - [ -n "$udp" ] && args+=" -u $udp" - - echo "$args" - return 0 -} - -# ###################################### -# Network mode setup -# ###################################### - -configureVTAP() { - - local msg dev - - enabled "$DEBUG" && echo "Configuring MACVTAP networking..." - - # Create the necessary file structure for /dev/vhost-net - if [ ! -c /dev/vhost-net ]; then - if mknod /dev/vhost-net c 10 238; then - chmod 660 /dev/vhost-net - fi - fi - - # Create a macvtap network for the VM guest - { msg=$(ip link add link "$DEV" name "$TAP" address "$MAC" type macvtap mode bridge 2>&1); local rc=$?; } || : - - case "$msg" in - "RTNETLINK answers: File exists"* ) - while ! ip link add link "$DEV" name "$TAP" address "$MAC" type macvtap mode bridge; do - info "Waiting for macvtap interface to become available.." - sleep 5 - done ;; - "RTNETLINK answers: Invalid argument"* ) - error "Cannot create macvtap interface. Please make sure that the network type of the container is 'macvlan' and not 'ipvlan'." - return 1 ;; - "RTNETLINK answers: Operation not permitted"* ) - error "No permission to create macvtap interface. Please make sure that your host kernel supports it and that the NET_ADMIN capability is set." - return 1 ;; - *) - [ -n "$msg" ] && echo "$msg" >&2 - if (( rc != 0 )); then - error "Cannot create macvtap interface." - return 1 - fi ;; - esac - - if [[ "$GUEST_MTU" != "0" ]]; then - setMTU "$TAP" "$GUEST_MTU" - GUEST_MTU=$(minMTU "$GUEST_MTU" "$(getMTU "$TAP")") - fi - - while ! ip link set "$TAP" up; do - info "Waiting for MAC address $MAC to become available..." - info "If you cloned this machine, please delete the 'dsm.mac' file to generate a different MAC address." - sleep 2 - done - - local TAP_NR MAJOR MINOR - - if ! dev=$(cat /sys/devices/virtual/net/"$TAP"/tap*/dev); then - error "Failed to determine device numbers for MACVTAP interface \"$TAP\" !" - return 1 - fi - - IFS=: read -r MAJOR MINOR <<< "$dev" - - if [[ ! "$MAJOR" =~ ^[0-9]+$ || ! "$MINOR" =~ ^[0-9]+$ ]]; then - error "Failed to parse device numbers for MACVTAP interface \"$TAP\" !" - return 1 - fi - - if (( MAJOR < 1 )); then - error "Cannot find: sys/devices/virtual/net/$TAP" - return 1 - fi - - if ! TAP_NR=$(<"/sys/class/net/$TAP/ifindex"); then - error "Failed to determine interface index of MACVTAP interface \"$TAP\" !" - return 1 - fi - - # Create dev file (there is no udev in container: need to be done manually) - local TAP_PATH="/dev/tap${TAP_NR}" - - [[ ! -e "$TAP_PATH" && -e "/dev0/${TAP_PATH##*/}" ]] && - ln -s "/dev0/${TAP_PATH##*/}" "$TAP_PATH" - - if [[ ! -e "$TAP_PATH" ]]; then - { mknod "$TAP_PATH" c "$MAJOR" "$MINOR"; rc=$?; } || : - (( rc != 0 )) && error "Cannot mknod: $TAP_PATH ($rc)" && return 1 - fi - - { exec 30>>"$TAP_PATH"; rc=$?; } 2>/dev/null || : - - if (( rc != 0 )); then - error "Cannot create TAP interface ($rc). $ADD_ERR --device-cgroup-rule='c *:* rwm'" && return 1 - fi - - { exec 40>>/dev/vhost-net; rc=$?; } 2>/dev/null || : - - if (( rc != 0 )); then - error "VHOST can not be found ($rc). $ADD_ERR --device=/dev/vhost-net" && return 1 - fi - - NET_OPTS="-netdev tap,id=hostnet0,vhost=on,vhostfd=40,fd=30" - - return 0 -} - -configureSlirp() { - - NETWORK="slirp" - enabled "$DEBUG" && echo "Configuring slirp networking..." - - local ip="$UPLINK" - [ -n "$IP" ] && ip="$IP" - - ip=$(guestIP "$ip" 4) - local gateway="${ip%.*}.1" - local subnet - subnet=$(networkCIDR "$ip") || return 1 - - local ipv6="ipv6=off," - [ -n "$IP6" ] && ipv6="ipv6=on," - - NET_OPTS="-netdev user,id=hostnet0,ipv4=on,host=$gateway,net=$subnet,dhcpstart=$ip,${ipv6}hostname=$HOST" - - local forward - forward=$(getSlirp "$ip") - [ -n "$forward" ] && NET_OPTS+=",$forward" - - if enabled "${DNSMASQ_DISABLE:-}"; then - if ! echo "$gateway" > /run/shm/qemu.gw; then - error "Failed to write gateway file." - return 1 - fi - else - if [ ! -f /etc/resolv.dnsmasq ] && ! cp /etc/resolv.conf /etc/resolv.dnsmasq; then - error "Failed to backup /etc/resolv.conf." - return 1 - fi - - configureDNS "lo" "$ip" "$MAC" "$HOST" "$MASK" "$gateway" || return 1 - - if ! printf '%s\n' \ - 'nameserver 127.0.0.1' \ - 'search .' \ - 'options ndots:0' > /etc/resolv.conf; then - error "Failed to update /etc/resolv.conf." - return 1 - fi - fi - - IP="$ip" - return 0 -} - -configurePasst() { - - NETWORK="passt" - enabled "$DEBUG" && echo "Configuring user-mode networking..." - - local log="/var/log/passt.log" - rm -f "$log" - - local ip="$UPLINK" - [ -n "$IP" ] && ip="$IP" - - ip=$(guestIP "$ip" 2) - local gateway="${ip%.*}.1" - - # passt configuration: - [ -z "$IP6" ] && PASST_OPTS+=" -4" - - PASST_OPTS+=" -a $ip" - PASST_OPTS+=" -g $gateway" - PASST_OPTS+=" -n $MASK" - - local passt_mtu="$GUEST_MTU" - [[ "$passt_mtu" == "0" ]] && passt_mtu="1500" - - # Pass an explicit MTU to passt. - PASST_OPTS+=" -m $passt_mtu" - - local forward - forward=$(getPasst) - [ -n "$forward" ] && PASST_OPTS+="$forward" - - PASST_OPTS+=" -H $HOST" - PASST_OPTS+=" -M $GATEWAY_MAC" - PASST_OPTS+=" --runas $EUID:$(id -g)" - PASST_OPTS+=" -P $PASST_PID" - PASST_OPTS+=" -s $PASST_SOCKET" - PASST_OPTS+=" -l $log" - PASST_OPTS+=" -q" - - if ! enabled "${DNSMASQ_DISABLE:-}"; then - if [ ! -f /etc/resolv.dnsmasq ] && ! cp /etc/resolv.conf /etc/resolv.dnsmasq; then - error "Failed to backup /etc/resolv.conf." - return 1 - fi - - if ! printf '%s\n' \ - 'nameserver 127.0.0.1' \ - 'search .' \ - 'options ndots:0' > /etc/resolv.conf; then - error "Failed to update /etc/resolv.conf." - return 1 - fi - fi - - PASST_OPTS=$(echo "$PASST_OPTS" | sed 's/\t/ /g' | tr -s ' ' | sed 's/^ *//') - - if enabled "$DEBUG" || enabled "$PASST_DEBUG"; then - printf "Passt arguments:\n\n%s\n\n" "${PASST_OPTS// -/$'\n-'}" - fi - - [ ! -f "$PASST" ] && cp /usr/bin/passt* /run - - if ! "$PASST" ${PASST_OPTS:+$PASST_OPTS} >/dev/null 2>&1; then - - rm -f "$log" - - PASST_OPTS="${PASST_OPTS/ -q/}" - { "$PASST" ${PASST_OPTS:+$PASST_OPTS}; local rc=$?; } || : - - if (( rc != 0 )); then - [ -f "$log" ] && [ -s "$log" ] && cat "$log" - warn "failed to start passt ($rc), falling back to slirp networking!" - configureSlirp && return 0 || return 1 - fi - - fi - - if enabled "$PASST_DEBUG"; then - tail -fn +0 "$log" --pid=$$ & - elif enabled "$DEBUG"; then - [ -f "$log" ] && [ -s "$log" ] && cat "$log" && echo "" - fi - - NET_OPTS="-netdev stream,id=hostnet0,server=off,addr.type=unix,addr.path=$PASST_SOCKET" - - if ! configureDNS "lo" "$ip" "$MAC" "$HOST" "$MASK" "$gateway"; then - mKill "$PASST_PID" - rm -f "$PASST_PID" "$PASST_SOCKET" - return 1 - fi - - IP="$ip" - return 0 -} - -configureBridge() { - - local file="/etc/qemu/bridge.conf" - - [ -e "$file" ] && return 0 - mkdir -p "${file%/*}" || return 0 - echo "allow br0" > "$file" || return 0 - - return 0 -} - -createBridge() { - - local gateway="$1" - local msg - - # Create a bridge with a static IP for the VM guest - { msg=$(ip link add dev "$BRIDGE" type bridge 2>&1); local rc=$?; } || : - - if (( rc != 0 )); then - enabled "$ROOTLESS" && ! enabled "$DEBUG" && return 1 - [ -n "$msg" ] && echo "$msg" >&2 - - case "${msg,,}" in - *"operation not permitted"* | *"permission denied"* ) - warn "failed to create bridge. $ADD_ERR --cap-add NET_ADMIN" ;; - * ) - warn "failed to create bridge." ;; - esac - - return 1 - fi - - if [[ "$GUEST_MTU" != "0" ]]; then - setMTU "$BRIDGE" "$GUEST_MTU" - fi - - if ! ip address add "$gateway/$PREFIX" dev "$BRIDGE"; then - warn "failed to add IP address pool!" && return 1 - fi - - while ! ip link set "$BRIDGE" up; do - info "Waiting for IP address to become available..." - sleep 2 - done - - # NAT networking is IPv4-only; disable IPv6 on the guest bridge if possible. - disableIPv6 "$BRIDGE" - - return 0 -} - -createTap() { - - local tuntap="$1" - local msg - - # Set tap to the bridge created - { msg=$(ip tuntap add dev "$TAP" mode tap 2>&1); local rc=$?; } || : - - if (( rc != 0 )); then - enabled "$ROOTLESS" && ! enabled "$DEBUG" && return 1 - [ -n "$msg" ] && echo "$msg" >&2 - warn "$tuntap" - return 1 - fi - - if [[ "$GUEST_MTU" != "0" ]]; then - setMTU "$TAP" "$GUEST_MTU" - fi - - if ! ip link set dev "$TAP" address "$GATEWAY_MAC"; then - warn "failed to set gateway MAC address." - fi - - while ! ip link set "$TAP" up promisc on; do - info "Waiting for TAP to become available..." - sleep 2 - done - - # NAT networking is IPv4-only; disable IPv6 on the guest tap if possible. - disableIPv6 "$TAP" - - if ! ip link set dev "$TAP" master "$BRIDGE"; then - warn "failed to set master bridge!" && return 1 - fi - - return 0 -} - -# ###################################### -# IP tables -# ###################################### - -hasTable() { - - iptables -t "$1" -S > /dev/null 2>&1 -} - -getTablesBackend() { - - local version - version=$(iptables --version 2>/dev/null || true) - - case "$version" in - *nf_tables* ) echo "nft" ;; - *legacy* ) echo "legacy" ;; - * ) return 1 ;; - esac -} - -setTables() { - - local mode="$1" - local path - - path=$(command -v "iptables-$mode" 2>/dev/null || true) - [ -z "$path" ] && return 1 - - update-alternatives --set iptables "$path" > /dev/null 2>&1 -} - -showRules() { - - local table="$1" - local chain="$2" - local label="$3" - local rule_tag="$4" - local rules - local own_rule="--comment[[:space:]]+\"?$rule_tag\"?([[:space:]]|\$)" - - enabled "$DEBUG" || return 0 - - rules=$( - iptables -t "$table" -S "$chain" 2>/dev/null | - awk '$1 == "-A"' | - grep -Ev -- "$own_rule" || true - ) - - [ -n "$rules" ] || return 0 - - printf "Existing %s rules:\n\n%s\n\n" "$label" "$rules" - return 0 -} - -checkExistingTables() { - - local rules conflicts - local rule_tag="QEMU_DNAT" - local own_rule="--comment[[:space:]]+\"?$rule_tag\"?([[:space:]]|\$)" - - rules=$( - { - iptables -t nat -S PREROUTING 2>/dev/null || true - iptables -t nat -S OUTPUT 2>/dev/null || true - } | - awk '$1 == "-A"' | - grep -Ev -- "$own_rule" || true - ) - - conflicts=$(grep -E -- \ - '^-A (PREROUTING|OUTPUT) .*(-j DNAT|-j REDIRECT)( |$)' \ - <<< "$rules" || true) - - if [ -n "$conflicts" ]; then - local msg="your existing NAT rules may take precedence over VM port forwarding" - - if enabled "$DEBUG"; then - warn "${msg}." - else - warn "${msg}; enable DEBUG=Y to inspect them." - fi - fi - - rules=$( - iptables -t filter -S FORWARD 2>/dev/null | - awk '$1 == "-A"' | - grep -Ev -- "$own_rule" || true - ) - - conflicts=$(grep -E -- \ - '^-A FORWARD .*(-j DROP|-j REJECT)( |$)' \ - <<< "$rules" || true) - - if [ -n "$conflicts" ]; then - local msg="your existing firewall rules may block traffic forwarded to or from the VM" - - if enabled "$DEBUG"; then - warn "${msg}." - else - warn "${msg}; enable DEBUG=Y to inspect them." - fi - fi - - showRules nat PREROUTING "NAT PREROUTING" "$rule_tag" - showRules nat OUTPUT "NAT OUTPUT" "$rule_tag" - showRules filter FORWARD "filter FORWARD" "$rule_tag" - showRules nat POSTROUTING "NAT POSTROUTING" "$rule_tag" - - if hasTable mangle; then - showRules mangle FORWARD "mangle FORWARD" "$rule_tag" - showRules mangle POSTROUTING "mangle POSTROUTING" "$rule_tag" - else - warn "the mangle iptable is unavailable, so checksum correction and TCP MSS clamping rules will be skipped." - fi - - return 0 -} - -runTableRule() { - - local silent="$1" - local result="$2" - local msg - - shift 2 - - printf -v "$result" '%s' "" - - { msg=$("$@" 2>&1); local rc=$?; } || : - (( rc == 0 )) && return 0 - - printf -v "$result" '%s' "$msg" - - if ! enabled "$silent" || enabled "$DEBUG"; then - [ -n "$msg" ] && echo "$msg" >&2 - fi - - return 1 -} - -tableError() { - - local silent="$1" - local message="${2,,}" - - if enabled "$silent" && ! enabled "$DEBUG"; then - return 1 - fi - - case "$message" in - *"permission denied"* | *"operation not permitted"* ) - warn "IP tables access was denied. Add the NET_ADMIN capability or use user-mode networking." - ;; - *"table does not exist"* | *"can't initialize iptables table"* ) - warn "The required IP tables kernel modules may be unavailable. Try: sudo modprobe ip_tables iptable_nat" - ;; - *"no chain/target/match by that name"* ) - warn "A required IP tables target or match is unavailable in the host kernel." - ;; - *"could not fetch rule set generation id"* ) - warn "The nftables backend is unavailable or inaccessible in this container." - ;; - * ) - warn "Failed to configure IP tables. Verify NET_ADMIN access and host IP tables support." - ;; - esac - - return 1 -} - -showTableCleanupError() { - - local command="$1" - local message="$2" - - enabled "$DEBUG" || return 0 - - printf "Failed IP tables cleanup command:\n\n%s\n\n" "$command" >&2 - [ -n "$message" ] && printf "%s\n\n" "$message" >&2 - - return 0 -} - -applyTables() { - - local ip="$1" - local subnet="$2" - local silent="${3:-N}" - local exclude port - local table_error - local dnat_chain="QEMU_DNAT" - local rule_tag="$dnat_chain" - - exclude=$(getHostPorts) - - # NAT traffic from the VM subnet leaving through any external interface. - if ! runTableRule "$silent" table_error \ - iptables -t nat -A POSTROUTING \ - ! -o "$BRIDGE" \ - -s "$subnet" \ - ! -d "$subnet" \ - -m comment --comment "$rule_tag" \ - -j MASQUERADE; then - tableError "$silent" "$table_error" - return 1 - fi - - # Use a dedicated chain so protected TCP ports do not depend on multiport support. - if ! runTableRule "$silent" table_error \ - iptables -t nat -N "$dnat_chain"; then - tableError "$silent" "$table_error" - return 1 - fi - - # Keep container-owned TCP ports handled by the container. - for port in ${exclude//,/ }; do - - [ -z "$port" ] && continue - - if ! runTableRule "$silent" table_error \ - iptables -t nat -A "$dnat_chain" \ - -p tcp \ - --dport "$port" \ - -m comment --comment "$rule_tag" \ - -j RETURN; then - tableError "$silent" "$table_error" - return 1 - fi - - done - - # Forward every remaining protocol and port to the VM. - if ! runTableRule "$silent" table_error \ - iptables -t nat -A "$dnat_chain" \ - -m comment --comment "$rule_tag" \ - -j DNAT --to "$ip"; then - tableError "$silent" "$table_error" - return 1 - fi - - # Process incoming traffic addressed to the container through the VM chain. - if ! runTableRule "$silent" table_error \ - iptables -t nat -A PREROUTING \ - ! -i "$BRIDGE" \ - -m addrtype --dst-type LOCAL \ - -m comment --comment "$rule_tag" \ - -j "$dnat_chain"; then - tableError "$silent" "$table_error" - return 1 - fi - - # Process locally generated traffic addressed to the container uplink. - if ! runTableRule "$silent" table_error \ - iptables -t nat -A OUTPUT \ - -d "$UPLINK" \ - -m addrtype --dst-type LOCAL \ - -m comment --comment "$rule_tag" \ - -j "$dnat_chain"; then - tableError "$silent" "$table_error" - return 1 - fi - - # Hack for guest VMs complaining about "bad udp checksums in 5 packets". - runTableRule "Y" table_error \ - iptables -t mangle -A POSTROUTING \ - -s "$subnet" \ - -p udp \ - --dport bootpc \ - -m comment --comment "$rule_tag" \ - -j CHECKSUM --checksum-fill || true - - # Clamp TCP MSS to avoid subtle MTU blackholes when the outer path has a smaller MTU. - runTableRule "Y" table_error \ - iptables -t mangle -A FORWARD \ - -s "$subnet" \ - -p tcp \ - --tcp-flags SYN,RST SYN \ - -m comment --comment "$rule_tag" \ - -j TCPMSS --clamp-mss-to-pmtu || true - - runTableRule "Y" table_error \ - iptables -t mangle -A FORWARD \ - -d "$ip" \ - -p tcp \ - --tcp-flags SYN,RST SYN \ - -m comment --comment "$rule_tag" \ - -j TCPMSS --clamp-mss-to-pmtu || true - - # Allow forwarding from the VM bridge to external interfaces. - if ! runTableRule "$silent" table_error \ - iptables -A FORWARD \ - -i "$BRIDGE" \ - ! -o "$BRIDGE" \ - -s "$subnet" \ - -m comment --comment "$rule_tag" \ - -j ACCEPT; then - tableError "$silent" "$table_error" - return 1 - fi - - # Allow forwarding from external interfaces to the VM. - if ! runTableRule "$silent" table_error \ - iptables -A FORWARD \ - ! -i "$BRIDGE" \ - -o "$BRIDGE" \ - -d "$ip" \ - -m comment --comment "$rule_tag" \ - -j ACCEPT; then - tableError "$silent" "$table_error" - return 1 - fi - - return 0 -} - -clearTables() { - - local line - local rules remaining message - local dnat_chain="QEMU_DNAT" - local rule_tag="$dnat_chain" - local own_rule="--comment[[:space:]]+\"?$rule_tag\"?([[:space:]]|\$)" - local remaining_rule="^:${dnat_chain}[[:space:]]|$own_rule" - - # Return 2 when the currently selected backend cannot be accessed. - # This lets configureTables() distinguish it from an actual cleanup failure. - if ! rules=$(iptables-save 2> /dev/null); then - - if enabled "$DEBUG"; then - message=$(iptables-save 2>&1 > /dev/null || true) - showTableCleanupError "iptables-save" "$message" - fi - - return 2 - fi - - if [ -n "$rules" ]; then - - # Delete tagged rules outside the dedicated DNAT chain, - # leaving all other rules intact. - while IFS= read -r line; do - - case "$line" in - \*nat ) local table="nat" ;; - \*filter ) local table="filter" ;; - \*mangle ) local table="mangle" ;; - \*raw ) local table="raw" ;; - esac - - if [[ "$line" == -A* ]] && [[ "$line" =~ $own_rule ]]; then - - local chain="${line#-A }" - chain="${chain%% *}" - - # Rules inside this chain are removed together by the flush below. - if [[ "$table" == "nat" && "$chain" == "$dnat_chain" ]]; then - continue - fi - - line="${line/-A /-D }" - - # Parse the quoting produced by iptables-save before deleting the rule. - if ! message=$( - printf '%s\n' "$line" | - xargs -r iptables -t "$table" 2>&1 - ); then - showTableCleanupError "iptables -t $table $line" "$message" - fi - - fi - - done <<< "$rules" - - fi - - # Remove the dedicated DNAT chain after deleting its references. - if iptables -t nat -S "$dnat_chain" > /dev/null 2>&1; then - - if ! message=$(iptables -t nat -F "$dnat_chain" 2>&1); then - showTableCleanupError "iptables -t nat -F $dnat_chain" "$message" - fi - - if ! message=$(iptables -t nat -X "$dnat_chain" 2>&1); then - showTableCleanupError "iptables -t nat -X $dnat_chain" "$message" - fi - - fi - - # Base the result on the final ruleset instead of intermediate errors. - if ! rules=$(iptables-save 2> /dev/null); then - - if enabled "$DEBUG"; then - message=$(iptables-save 2>&1 > /dev/null || true) - showTableCleanupError "iptables-save" "$message" - fi - - return 1 - fi - - remaining=$(grep -E -- "$remaining_rule" <<< "$rules" || true) - - if [ -n "$remaining" ]; then - - if enabled "$DEBUG"; then - warn "IP tables cleanup left the following rules or chains behind:" - echo "$remaining" >&2 - fi - - return 1 - fi - - return 0 -} - -hasTaggedRules() { - - local save="$1" - local rules - local dnat_chain="QEMU_DNAT" - local rule_tag="$dnat_chain" - local own_rule="--comment[[:space:]]+\"?$rule_tag\"?([[:space:]]|\$)" - local tagged_rule="^:${dnat_chain}[[:space:]]|$own_rule" - - # Return 2 when the backend cannot be inspected. - if ! rules=$("$save" 2>/dev/null); then - return 2 - fi - - if grep -Eq -- "$tagged_rule" <<< "$rules"; then - return 0 - fi - - return 1 -} - -configureTables() { - - local ip="$1" - local subnet="$2" - local preferred - local alternate_save - local preferred_clean="N" - local alternate_dirty="N" - - preferred=$(getTablesBackend) || { - enabled "$ROOTLESS" && ! enabled "$DEBUG" && return 1 - warn "failed to determine the active IP tables backend!" - return 1 - } - - case "$preferred" in - "nft" ) local alternate="legacy" ;; - "legacy" ) local alternate="nft" ;; - * ) - enabled "$ROOTLESS" && ! enabled "$DEBUG" && return 1 - warn "unsupported IP tables backend: $preferred" - return 1 ;; - esac - - # Inspect the alternate backend without changing the active alternative. - alternate_save=$(command -v "iptables-$alternate-save" 2>/dev/null || true) - - if [ -n "$alternate_save" ]; then - - if hasTaggedRules "$alternate_save"; then - - # Only switch backends when stale QEMU rules were positively found. - if ! setTables "$alternate"; then - enabled "$ROOTLESS" && ! enabled "$DEBUG" && return 1 - warn "failed to select the $alternate IP tables backend for cleanup!" - return 1 - fi - - if ! clearTables; then - alternate_dirty="Y" - fi - - # Always restore the originally selected backend after cleanup. - if ! setTables "$preferred"; then - enabled "$ROOTLESS" && ! enabled "$DEBUG" && return 1 - warn "failed to restore the preferred $preferred IP tables backend!" - return 1 - fi - - if enabled "$alternate_dirty"; then - enabled "$ROOTLESS" && ! enabled "$DEBUG" && return 1 - warn "failed to clean up the existing $alternate IP tables configuration!" - return 1 - fi - - else - - local rc=$? - - # An unavailable alternate backend does not affect normal startup. - if (( rc == 2 )) && enabled "$DEBUG"; then - warn "failed to inspect the $alternate IP tables backend!" - fi - - fi - - fi - - # Try the preferred backend first. - if clearTables; then - - preferred_clean="Y" - - # Try the preferred backend without reporting provisional failures. - if applyTables "$ip" "$subnet" "Y"; then - checkExistingTables - return 0 - fi - - # Never switch backends while partial rules remain in the preferred backend. - if ! clearTables; then - enabled "$ROOTLESS" && ! enabled "$DEBUG" && return 1 - warn "failed to clean up the partial $preferred IP tables configuration!" - return 1 - fi - - else - - local rc=$? - - # The preferred backend was accessible, but its rules could not be removed. - # Do not switch while partial or stale rules may still be active. - if (( rc == 1 )); then - enabled "$ROOTLESS" && ! enabled "$DEBUG" && return 1 - warn "failed to clean up the existing $preferred IP tables configuration!" - return 1 - fi - - # Return code 2 means the preferred backend itself could not be accessed, - # so it is safe to try the alternate backend. - if (( rc != 2 )); then - enabled "$ROOTLESS" && ! enabled "$DEBUG" && return 1 - warn "failed to access the $preferred IP tables backend!" - return 1 - fi - - if enabled "$DEBUG"; then - warn "failed to access the $preferred IP tables backend!" - fi - - fi - - # Try the alternate backend when the preferred backend failed. - if setTables "$alternate"; then - - # Remove rules left by a previous run from the alternate backend. - if clearTables; then - - if applyTables "$ip" "$subnet" "Y"; then - checkExistingTables - return 0 - fi - - if ! clearTables; then - - alternate_dirty="Y" - - if ! enabled "$ROOTLESS" || enabled "$DEBUG"; then - warn "failed to clean up the partial $alternate IP tables configuration!" - fi - - fi - - else - - local rc=$? - - # Only mark the alternate backend dirty when it was accessible but cleanup failed. - if (( rc == 1 )); then - - alternate_dirty="Y" - - if ! enabled "$ROOTLESS" || enabled "$DEBUG"; then - warn "failed to clean up the existing $alternate IP tables configuration!" - fi - - elif (( rc != 2 )); then - - alternate_dirty="Y" - - if ! enabled "$ROOTLESS" || enabled "$DEBUG"; then - warn "failed to inspect the existing $alternate IP tables configuration!" - fi - - elif enabled "$DEBUG"; then - warn "failed to access the $alternate IP tables backend!" - fi - - fi - fi - - # Restore the preferred backend after the alternate attempt failed. - if ! setTables "$preferred"; then - enabled "$ROOTLESS" && ! enabled "$DEBUG" && return 1 - warn "failed to restore the preferred $preferred IP tables backend!" - return 1 - fi - - # Do not continue while partial rules remain in the alternate backend. - enabled "$alternate_dirty" && return 1 - - # Both backend failures were already shown in debug mode. - enabled "$DEBUG" && return 1 - - # Rootless NAT failures should remain silent before falling back. - enabled "$ROOTLESS" && return 1 - - # An inaccessible preferred backend cannot be retried diagnostically. - if ! enabled "$preferred_clean"; then - warn "failed to access both IP tables backends!" - return 1 - fi - - # Verify that no rules remain before the diagnostic attempt. - if ! clearTables; then - warn "failed to clean up the existing $preferred IP tables configuration!" - return 1 - fi - - # Repeat the preferred backend once to show its actual failure. - if applyTables "$ip" "$subnet" "N"; then - checkExistingTables - return 0 - fi - - # Do not leave a partial ruleset after the final failed attempt. - if ! clearTables; then - warn "failed to clean up the partial $preferred IP tables configuration!" - fi - - return 1 -} - -addUpstream() { - - local upstream="$1" - local table_error - local rule_tag="QEMU_DNAT" - - [ -n "$upstream" ] || return 1 - [ -n "$GATEWAY" ] || return 1 - - if ! ip address add "$upstream/32" dev "$BRIDGE"; then - if ! enabled "$ROOTLESS" || enabled "$DEBUG"; then - warn "failed to add the system.lan address; access through that name will be unavailable." - fi - return 1 - fi - - if ! runTableRule "Y" table_error \ - iptables -t nat -A PREROUTING \ - -i "$BRIDGE" \ - -d "$upstream" \ - -m comment --comment "$rule_tag" \ - -j DNAT --to-destination "$GATEWAY"; then - - ip address del "$upstream/32" dev "$BRIDGE" > /dev/null 2>&1 || : - - if ! enabled "$ROOTLESS" || enabled "$DEBUG"; then - [ -n "$table_error" ] && echo "$table_error" >&2 - warn "failed to configure system.lan forwarding; access through that name will be unavailable." - fi - - return 1 - fi - - return 0 -} - -configureNAT() { - - local tuntap="TUN device is missing. $ADD_ERR --device /dev/net/tun" - local ip subnet upstream="" forwarding="" - - enabled "$DEBUG" && echo "Configuring NAT networking..." - - # Create the necessary file structure for /dev/net/tun - if [ ! -c /dev/net/tun ]; then - [ ! -d /dev/net ] && mkdir -m 755 /dev/net > /dev/null 2>&1 || : - - local msg - { msg=$(mknod /dev/net/tun c 10 200 2>&1); local rc=$?; } || : - - if (( rc == 0 )); then - chmod 666 /dev/net/tun - elif ! enabled "$ROOTLESS" || enabled "$DEBUG"; then - [ -n "$msg" ] && echo "$msg" >&2 - fi - fi - - if [ ! -c /dev/net/tun ]; then - enabled "$ROOTLESS" && ! enabled "$DEBUG" && return 1 - warn "$tuntap" && return 1 - fi - - # Check port forwarding flag - [ -r /proc/sys/net/ipv4/ip_forward ] && - forwarding=$(< /proc/sys/net/ipv4/ip_forward) - - if [[ "$forwarding" != "1" ]]; then - { sysctl -w net.ipv4.ip_forward=1 > /dev/null 2>&1; local rc=$?; } || : - - forwarding="" - [ -r /proc/sys/net/ipv4/ip_forward ] && - forwarding=$(< /proc/sys/net/ipv4/ip_forward) - - if (( rc != 0 )) || [[ "$forwarding" != "1" ]]; then - enabled "$ROOTLESS" && ! enabled "$DEBUG" && return 1 - warn "IP forwarding is disabled. $ADD_ERR --sysctl net.ipv4.ip_forward=1" - return 1 - fi - fi - - if [ -n "$IP" ]; then - ip=$(guestIP "$IP" 2) - else - ip=$(natGuestIP "$UPLINK") || return 1 - fi - - local gateway="${ip%.*}.1" - subnet=$(networkCIDR "$ip") || return 1 - - if [ -n "$GATEWAY" ]; then - upstream=$(upstreamIP "$subnet" "$ip" "$gateway") || upstream="" - fi - - if subnetInUse "$subnet"; then - error "VM subnet $subnet conflicts with an existing route inside the container." - return 1 - else - local rc=$? - (( rc == 1 )) || return 1 - fi - - createBridge "$gateway" || return 1 - createTap "$tuntap" || return 1 - - # Use the lowest effective guest-facing MTU, without mutating the parent/uplink MTU. - if [[ "$GUEST_MTU" != "0" ]]; then - GUEST_MTU=$(minMTU "$GUEST_MTU" "$(getMTU "$BRIDGE")" "$(getMTU "$TAP")") - fi - - configureTables "$ip" "$subnet" || return 1 - - if [ -n "$upstream" ] && ! addUpstream "$upstream"; then - upstream="" - fi - - NET_OPTS="-netdev tap,id=hostnet0,ifname=$TAP" - - if [ -c /dev/vhost-net ]; then - { exec 40>>/dev/vhost-net; local rc=$?; } 2>/dev/null || : - (( rc == 0 )) && NET_OPTS+=",vhost=on,vhostfd=40" - fi - - NET_OPTS+=",script=no,downscript=no" - - configureDNS "$BRIDGE" "$ip" "$MAC" "$HOST" "$MASK" "$gateway" "$upstream" || return 1 - - IP="$ip" - return 0 -} - -# ###################################### -# Cleanup -# ###################################### - -closeInterfaces() { - - local pids=( "$PASST_PID" "$DNSMASQ_PID" ) - mKill "${pids[@]}" - - exec 30<&- || : - exec 40<&- || : - - ip link set "$TAP" down promisc off &> /dev/null || : - ip link delete "$TAP" &> /dev/null || : - - ip link set "$BRIDGE" down &> /dev/null || : - ip link delete "$BRIDGE" &> /dev/null || : - - clearTables || : - return 0 -} - -closeWeb() { - - local pids=( "${WEB_PID:-}" "${WSD_PID:-}" ) - mKill "${pids[@]}" - - return 0 -} - -closeNetwork() { - - if ! disabled "${WEB:-}" && enabled "$DHCP"; then - closeWeb - fi - - disabled "$NETWORK" && return 0 - - closeInterfaces - - return 0 -} - -# ###################################### -# Detection -# ###################################### - -checkOS() { - - local iface="macvlan" - local os="" kernel - - kernel=$(uname -a) - - [[ "${kernel,,}" == *"darwin"* ]] && os="$ENGINE Desktop for macOS" - [[ "${kernel,,}" == *"microsoft"* ]] && os="$ENGINE Desktop for Windows" - - if enabled "$DHCP"; then - iface="macvtap" - [[ "${kernel,,}" == *"synology"* ]] && os="Synology Container Manager" - fi - - if [ -n "$os" ]; then - warn "you are using $os which does not support $iface, please revert to bridge networking!" - fi - - return 0 -} - -validateInterface() { - - if [ ! -d "/sys/class/net/$DEV" ]; then - error "Network interface '$DEV' does not exist inside the container!" - error "$ADD_ERR -e \"DEV=NAME\" to specify another interface name." - exit 26 - fi - - return 0 -} - -validateMask() { - - PREFIX=$(maskToCIDR "$MASK") || exit 28 - - if ! enabled "$DHCP" && (( PREFIX < 16 || PREFIX > 24 )); then - error "Unsupported MASK: '$MASK' (supported range: /16 through /24)" - exit 28 - fi - - return 0 -} - -validateHost() { - - HOST="${HOST//[^A-Za-z0-9-]/-}" - HOST=$(echo "$HOST" | sed 's/^-*//;s/-*$//;s/--*/-/g') - - if [ -z "$HOST" ]; then - HOST="$APP" - HOST="${HOST//[^A-Za-z0-9-]/-}" - HOST=$(echo "$HOST" | sed 's/^-*//;s/-*$//;s/--*/-/g') - fi - - return 0 -} - -validateHostPorts() { - - local custom - custom=$(getCustomHostPorts "all") - - if isNAT && [[ "$custom" == *"/udp"* ]]; then - warn "UDP ports in \"HOST_PORTS\" are not yet implemented for NAT networking." - fi - - return 0 -} - -validateAddresses() { - - # DHCP/macvtap mode can work without a detectable container IPv4 address, - # because the guest receives its address directly from the external LAN. - if [ -z "$UPLINK" ] && ! enabled "$DHCP"; then - error "Could not determine container IPv4 address!" - exit 26 - fi - - return 0 -} - -validateAdapter() { - - if [[ -n "$BUS" && "${BUS,,}" != "n/a" && "${BUS,,}" != "tap" ]]; then - enabled "$DEBUG" && info "Detected NIC: ${NIC:-unknown} BUS: $BUS" - error "This container does not support host mode networking!" - exit 29 - fi - - if enabled "$DHCP"; then - - checkOS - - if [[ "${NIC,,}" == "ipvlan" ]]; then - error "This container does not support IPVLAN networking when DHCP=Y." - exit 29 - fi - - if [[ "${NIC,,}" != "macvlan" ]]; then - enabled "$DEBUG" && info "Detected NIC: ${NIC:-unknown}" - error "The container needs to be in a MACVLAN network when DHCP=Y." - exit 29 - fi - - if uname -a | grep -Eqi 'unraid|truenas'; then - - # Check if host exposes the bridge-nf sysctl - # (only visible if br_netfilter is loaded and /proc/sys is accessible) - - local bnf="/proc/sys/net/bridge/bridge-nf-call-iptables" - - if [[ -r "$bnf" ]] && [[ "$(<"$bnf")" != "0" ]]; then - warn "external LAN clients may not be able to reach this container, because net.bridge.bridge-nf-call-iptables=1." - warn "you can fix this issue by running 'sysctl -w net.bridge.bridge-nf-call-iptables=0' on the host system." - fi - - fi - - else - - if [[ "$UPLINK" != "172."* && "$UPLINK" != "10.8"* && "$UPLINK" != "10.9"* ]]; then - checkOS - fi - - fi - - return 0 -} - -configureMTU() { - - local mtu="" - local mtu_custom="N" - - if [ -f "/sys/class/net/$DEV/mtu" ]; then - mtu=$(< "/sys/class/net/$DEV/mtu") - fi - - [ -n "$MTU" ] && mtu_custom="Y" - [ -z "$MTU" ] && MTU="$mtu" - [ -z "$MTU" ] && MTU="0" - - GUEST_MTU="$MTU" - - # Automatically propagate smaller-than-standard MTUs, but do not automatically - # advertise jumbo frames unless the user explicitly requested MTU. - if [[ "$GUEST_MTU" != "0" && "$GUEST_MTU" -gt "1500" ]] && ! enabled "$mtu_custom"; then - GUEST_MTU="1500" - fi - - return 0 -} - -configureMAC() { - - local container - container=$(containerID) - - if [ -z "$MAC" ]; then - - local file="$STORAGE/dsm.mac" - - if [ -s "$file" ]; then - if ! MAC=$(readFile "$file"); then - error "Failed to read MAC address from \"$file\" !" - exit 28 - fi - fi - - if [ -z "$MAC" ]; then - - # Generate a Synology-style MAC address based on a stable container identifier when possible. - MAC=$(echo "$container" | md5sum | sed 's/^\(..\)\(..\)\(..\)\(..\)\(..\).*$/02:11:32:\3:\4:\5/') - - if ! writeFile "${MAC^^}" "$file"; then - error "Failed to write MAC address to \"$file\" !" - exit 28 - fi - - fi - fi - - MAC="${MAC^^}" - MAC="${MAC//-/:}" - - if [[ ${#MAC} == 12 ]]; then - local m="$MAC" - MAC="${m:0:2}:${m:2:2}:${m:4:2}:${m:6:2}:${m:8:2}:${m:10:2}" - fi - - if [[ ${#MAC} != 17 ]]; then - error "Invalid MAC address: '$MAC', should be 12 or 17 digits long!" - exit 28 - fi - - # Keep the guest-facing gateway MAC stable across runs. - GATEWAY_MAC=$(gatewayMAC "$MAC") - - return 0 -} - -showHostInfo() { - - local mtu host uplink prefix - - prefix=$(ip -4 -o address show dev "$DEV" scope global 2>/dev/null | - awk -v ip="$UPLINK" ' - { - split($4, address, "/") - if (address[1] == ip) { - print address[2] - exit - } - } - ') - - uplink=$(formatAddress "$UPLINK" "$prefix" || true) - [ -z "$uplink" ] && uplink="(none)" - - local line="❯ Host: $uplink" - - host=$(containerID) - [ -n "$host" ] && line+=" ($host)" - - local obvious="" - if [[ "$UPLINK" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)\.[0-9]+$ ]]; then - obvious="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}.1" - fi - - local gateway="${GATEWAY:-}" - if [ -z "$gateway" ]; then - line+=" | Gateway: (none)" - elif [[ "$gateway" != "$obvious" ]]; then - line+=" | Gateway: $gateway" - fi - - local iface="$DEV" - if [ -n "$NIC" ] && [[ "${NIC,,}" != "veth" ]]; then - iface+="/$NIC" - fi - - [ -z "$iface" ] && iface="(none)" - [[ "$iface" != "eth0" ]] && line+=" | Interface: $iface" - - mtu=$(getMTU "$DEV") - if [ -n "$mtu" ] && [[ "$mtu" != "0" && "$mtu" != "1500" ]]; then - line+=" | MTU: $mtu" - fi - - local nameservers="" - local file="/etc/resolv.dnsmasq" - [ ! -f "$file" ] && file="/etc/resolv.conf" - - if [ -f "$file" ]; then - nameservers=$(awk '$1 == "nameserver" { print $2 }' "$file" | - paste -sd ',' | - sed 's/,/, /g' || true) - fi - - [ -z "$nameservers" ] && nameservers="(none)" - [[ "$nameservers" == "127.0.0.1"* ]] && nameservers="" - - echo - - if (( ${#nameservers} <= 40 )); then - [ -n "$nameservers" ] && line+=" | DNS: $nameservers" - echo "$line" - else - echo "$line" - echo "❯ DNS: $nameservers" - fi - - enabled "$DEBUG" && echo - return 0 -} - -showGuestInfo() { - - local ip="${IP:-}" - - [ -n "$ip" ] && ip=$(formatAddress "$ip" "$PREFIX" || true) - [ -z "$ip" ] && ip="DHCP" - - local line="❯ Guest: $ip" - - if [ -n "${HOST:-}" ]; then - line+=" ($HOST)" - fi - - local mode="${NETWORK,,}" - - if enabled "$DHCP"; then - mode="DHCP" - elif isNAT; then - mode="NAT" - elif isUserMode; then - mode="User ($mode)" - elif [ -z "$mode" ]; then - mode="(none)" - fi - - line+=" | Mode: $mode" - - [ -n "$MAC" ] && line+=" | MAC: $MAC" - - echo "$line" - echo - return 0 -} - -initializeNetwork() { - - detectInterface - validateInterface - - validateMask - validateHost - validateHostPorts - - detectAddresses - validateAddresses - - detectAdapter - validateAdapter - - configureMTU - configureMAC - configureBridge - - showHostInfo - - if [[ "$UPLINK" == "172.17."* ]]; then - warn "your container IP starts with 172.17.* which will cause conflicts when you install the Container Manager package inside DSM!" - fi - - closeInterfaces - - # Clean up old files - rm -f "$PASST_PID" "$PASST_SOCKET" - rm -f "$DNSMASQ_PID" /etc/resolv.dnsmasq - - return 0 -} - -# ###################################### -# Configure Network -# ###################################### - -if disabled "$NETWORK"; then - NET_OPTS="" - return 0 -fi - -msg="Initializing network..." -html "$msg" -enabled "$DEBUG" && echo "$msg" - -initializeNetwork - -MSG="Booting DSM instance..." -html "$MSG" - -if enabled "$DHCP"; then - - # Configure for macvtap interface - configureVTAP || exit 20 - showGuestInfo - -else - - if ! disabled "${WEB:-}"; then - writeAtomic "$WSD_COMMAND" "portal" - sleep 1.2 - closeWeb - fi - - if isNAT; then - - # Configure tap interface - if ! configureNAT; then - - closeInterfaces - # NAT setup failure is recoverable: tear down partial interfaces and - # continue with the default user-mode backend. - NETWORK="user" - - if ! enabled "$ROOTLESS" || enabled "$DEBUG"; then - msg="falling back to user-mode networking!" - msg="failed to setup NAT networking, $msg" - warn "$msg" - fi - - fi - - fi - - if isUserMode; then - - case "${NETWORK,,}" in - "passt" | "user"* ) - - # Configure for user-mode networking (passt) - if ! configurePasst; then - error "Failed to configure user-mode networking!" - exit 24 - fi ;; - - "slirp" ) - - # Configure for user-mode networking (slirp) - if ! configureSlirp; then - error "Failed to configure user-mode networking!" - exit 24 - fi ;; - - esac - - elif ! isNAT; then - - error "Unrecognized NETWORK value: \"$NETWORK\"" && exit 24 - - fi - - showGuestInfo - - if isUserMode && [ -z "$USER_PORTS" ]; then - info "Notice: because user-mode networking is active, when you need to forward custom ports to DSM, add them to the \"USER_PORTS\" variable." - fi - -fi - -# Suppress the adapter option ROM because firmware network boot is unused and -# would otherwise alter boot order and startup timing. -NET_OPTS+=" -device $ADAPTER,id=net0,netdev=hostnet0,romfile=,mac=$MAC" - -if [[ "$GUEST_MTU" != "0" && "$GUEST_MTU" != "1500" ]]; then - if [[ "${ADAPTER,,}" == "virtio-net-pci" ]]; then - NET_OPTS+=",host_mtu=$GUEST_MTU" - elif [[ "$GUEST_MTU" -lt "1500" ]]; then - warn "MTU size is $GUEST_MTU, but cannot be advertised for $ADAPTER adapters; networking may break on paths below 1500 MTU." - fi -fi - -# Publish the container address and detected driver for the healthcheck and -# post-boot login-message helper. -if ! echo "$UPLINK" > "$QEMU_DIR"/qemu.ip; then - error "Failed to write QEMU IP file!" - exit 24 -fi - -if ! echo "$NIC" > "$QEMU_DIR"/qemu.nic; then - error "Failed to write QEMU NIC file!" - exit 24 -fi - -return 0 diff --git a/src/power.sh b/src/power.sh index 91d9d21..3435e06 100644 --- a/src/power.sh +++ b/src/power.sh @@ -17,198 +17,6 @@ CONSOLE_PID="$QEMU_DIR/console.pid" CONSOLE_SOCKET="$QEMU_DIR/console.sock" QEMU_START_PID="$QEMU_DIR/qemu.start.pid" -_trap() { - - local func="$1"; shift - local sig - - TRAP_PID=$BASHPID - - for sig; do - # Capture the local callback and signal while registering the trap. - # shellcheck disable=SC2064 - trap "$func $sig" "$sig" - done - - return 0 -} - -signalCode() { - - local sig="$1" - - case "$sig" in - SIGHUP) echo 129 ;; - SIGINT) echo 130 ;; - SIGQUIT) echo 131 ;; - SIGABRT) echo 134 ;; - SIGTERM) echo 143 ;; - *) echo 0 ;; - esac - - return 0 -} - -displayReason() { - - local reason="$1" - - case "$reason" in - 129 ) echo "SIGHUP" ;; - 130 ) echo "SIGINT" ;; - 131 ) echo "SIGQUIT" ;; - 134 ) echo "SIGABRT" ;; - 143 ) echo "SIGTERM" ;; - * ) echo "$reason" ;; - esac - - return 0 -} - -readQemuPid() { - - # Interactive startup uses a wrapper-created PID file before QEMU writes its - # own pidfile, so accept either during startup and shutdown races. - readPidFile "$1" "$QEMU_START_PID" && return 0 - readPidFile "$1" "$QEMU_PID" -} - -qemuPidFile() { - - local -n _file="$1" - - _file="$QEMU_PID" - [ -s "$QEMU_START_PID" ] && _file="$QEMU_START_PID" - - return 0 -} - -waitQemuExit() { - - local timeout="${1:-10}" - local file - - qemuPidFile file - waitPidFile "$file" "$timeout" -} - -waitQemuPid() { - - local cnt=0 - - while ! readQemuPid "$1"; do - sleep 0.02 - cnt=$((cnt + 1)) - (( cnt >= 50 )) && return 1 - done - - return 0 -} - -forceKillQemu() { - - local reason="$1" - local pid display - - readQemuPid pid || return 0 - isAlive "$pid" || return 0 - - display=$(displayReason "$reason") - error "Forcefully terminating $(app), reason: $display..." - { disown "$pid" || :; kill -9 -- "$pid" || :; } 2>/dev/null - - return 0 -} - -cleanupHelpers() { - - local pids=( "${HOST_PID:-}" "${WSD_PID:-}" "${CONSOLE_PID:-}" \ - "${WEB_PID:-}" "${PASST_PID:-}" "${DNSMASQ_PID:-}" ) - - mKill "${pids[@]}" - fKill "print.sh" - - rm -f -- "$HOST_API_SOCKET" "$HOST_AGENT_SOCKET" - - closeNetwork - return 0 -} - -startConsole() { - - local output="${1:-/dev/tty}" - local cnt=0 - - rm -f -- "$CONSOLE_SOCKET" "$CONSOLE_PID" - - if ! stty -icanon -echo isig -ixon min 1 time 0 "$output" - ) & - - local pid="$!" - echo "$pid" > "$CONSOLE_PID" - - while [ ! -S "$CONSOLE_SOCKET" ]; do - - if ! isAlive "$pid"; then - rm -f -- "$CONSOLE_PID" - error "Serial console relay exited unexpectedly!" - return 1 - fi - - sleep 0.02 - cnt=$((cnt + 1)) - - if (( cnt > 100 )); then - error "Failed to start serial console relay!" - return 1 - fi - - done - - return 0 -} - -stopConsole() { - - mKill "$CONSOLE_PID" - - return 0 -} - -startQemu() { - - rm -f -- "$QEMU_START_PID" - - # Launch QEMU in a separate session while recording the real child PID; - # setsid's wrapper PID is not suitable for guest shutdown or forced cleanup. - ( - trap '' INT QUIT - - # shellcheck disable=SC2016 - exec setsid -f -w sh -c ' - file=$1 - shift - - "$@" & - pid=$! - printf "%s\n" "$pid" > "$file" || exit 1 - - rc=0 - wait "$pid" 2>/dev/null || rc=$? - exit "$rc" - ' sh "$QEMU_START_PID" "$@" - ) /\>} - s=${s//'"'/\"} - s=${s//"'"/\'} - - printf '%s' "$s" - return 0 -} - -writeInfo() { - - local content="$1" - - # Replace the web status atomically so websocket readers never observe a - # partially written HTML fragment. - if ! printf '%s\n' "$content" > "$info_tmp"; then - rm -f -- "$info_tmp" - return 1 - fi - - if ! mv -f -- "$info_tmp" "$info"; then - rm -f -- "$info_tmp" - return 1 - fi - - return 0 -} - -getBytes() { - - local path="$1" - local mode="$2" - local bytes="0" - - if [[ "$mode" == "counter" ]]; then - if [ -r "$path" ]; then - read -r bytes < "$path" || bytes="0" - fi - - [[ "$bytes" =~ ^[0-9]+$ ]] || bytes="0" - printf '%s\n' "$bytes" - return 0 - fi - - if [ ! -s "$path" ] && [ ! -d "$path" ]; then - printf '0\n' - return 0 - fi - - if [[ "$mode" == "allocated" ]]; then - bytes=$(du -sB1 -- "$path" 2>/dev/null | cut -f1) || bytes="0" - else - bytes=$(du -sb -- "$path" 2>/dev/null | cut -f1) || bytes="0" - fi - - printf '%s\n' "$bytes" - return 0 -} - -getStatus() { - - local file="$1" - local bytes total extra="" - - [ -r "$file" ] || return 1 - read -r bytes total extra < "$file" || return 1 - - if [[ ! "$bytes" =~ ^[0-9]+$ || - ! "$total" =~ ^[0-9]+$ || - -n "$extra" ]]; then - return 1 - fi - - printf '%s %s\n' "$bytes" "$total" - return 0 -} - -formatSize() { - - local bytes="$1" - local size - - size=$(numfmt --to=iec --suffix=B "$bytes" | - sed -r 's/([A-Z])/ \1/') || - size="${bytes} bytes" - - printf '%s' "$size" - return 0 -} - -printPercentProgress() { - - local percent="$1" - - while (( next_percent <= percent && next_percent <= 100 )); do - if [[ "$printed" == "Y" ]]; then - printf ' → %s%%' "$next_percent" - else - printf '%s%%' "$next_percent" - fi - - printed="Y" - next_percent=$((next_percent + 10)) - done - - return 0 -} - -printCurrentSize() { - - local bytes="$1" - local size - - size=$(formatSize "$bytes") - - if [[ "$printed" == "Y" ]]; then - printf ' → %s' "$size" - else - printf '%s' "$size" - fi - - printed="Y" - return 0 -} - -printSizeProgress() { - - local bytes="$1" - local size - - while (( bytes >= next_bytes )); do - size=$(formatSize "$next_bytes") - - if [[ "$printed" == "Y" ]]; then - printf ' → %s' "$size" - else - printf '%s' "$size" - fi - - printed="Y" - next_bytes=$((next_bytes + step_bytes)) - done - - return 0 -} - -stopProgress() { - - if [ -z "$status_file" ]; then - exit 0 - fi - - stopping="Y" - return 0 -} - -finishProgress() { - - rm -f -- "$info_tmp" - - if [[ "$output" == "log" && "$printed" == "Y" ]]; then - printf '\n' - fi - - return 0 -} - -path="$1" -total="$2" -body=$(escape "$3") -output="${4:-}" -step_bytes="${5:-536870912}" -mode="${6:-apparent}" -status_file="${7:-}" - -if [[ -n "$total" && ! "$total" =~ ^(0|[1-9][0-9]*)$ ]]; then - printf 'Invalid total size: %s\n' "$total" >&2 - exit 2 -fi - -if [[ ! "$step_bytes" =~ ^[1-9][0-9]*$ ]]; then - printf 'Invalid progress interval: %s\n' "$step_bytes" >&2 - exit 2 -fi - -case "$mode" in - apparent | allocated | counter ) ;; - * ) - printf 'Invalid progress mode: %s\n' "$mode" >&2 - exit 2 - ;; -esac - -case "$output" in - "" | log ) ;; - * ) - printf 'Invalid progress output: %s\n' "$output" >&2 - exit 2 - ;; -esac - -printed="N" -next_percent=10 -next_bytes="$step_bytes" -log_mode="percent" -stopping="N" - -if [ -z "$total" ] || [[ "$total" == "0" ]]; then - log_mode="size" -fi - -trap finishProgress EXIT -trap 'exit 0' HUP INT QUIT -# SIGTERM requests one final measurement and web update rather than -# terminating between progress samples. -trap stopProgress TERM - -if [[ "$body" == *"..." ]]; then - body="

${body::-3}

" -fi - -while true; do - - final_pass="${stopping:-}" - bytes=$(getBytes "$path" "$mode") - effective_total="$total" - - # An external downloader may provide authoritative completed and total byte - # counters; use them instead of filesystem size when available. - if [ -n "$status_file" ] && status=$(getStatus "$status_file"); then - read -r status_bytes status_total <<< "$status" - bytes="$status_bytes" - - if (( status_total > 0 )); then - effective_total="$status_total" - fi - fi - - # A real total may become available shortly after aria2 starts. - if [[ "$log_mode" == "size" && - "$printed" == "N" && - -n "$effective_total" && - "$effective_total" != "0" ]]; then - log_mode="percent" - fi - - if (( bytes > 4096 )); then - - write_html="Y" - - if [ -z "$effective_total" ] || - [[ "$effective_total" == "0" ]] || - (( bytes > effective_total )); then - size=$(formatSize "$bytes") - - if [[ "$output" == "log" ]]; then - if [[ "$log_mode" == "percent" ]]; then - printCurrentSize "$bytes" - next_bytes=$(((bytes / step_bytes + 1) * step_bytes)) - log_mode="size" - else - printSizeProgress "$bytes" - fi - fi - else - # Floor the percentage rather than rounding so displayed completion - # never gets ahead of bytes actually written. - # Truncate to one decimal so progress is never reported early. - progress=$((bytes * 1000 / effective_total)) - (( progress > 1000 )) && progress=1000 - - percent=$((progress / 10)) - - printf -v size '%d.%d%%' \ - "$((progress / 10))" \ - "$((progress % 10))" - - if [[ "$output" == "log" ]]; then - if [[ "$log_mode" == "size" ]]; then - printSizeProgress "$bytes" - else - printPercentProgress "$percent" - fi - fi - - # Do not update the web viewer until at least 0.1% is reached. - (( progress == 0 )) && write_html="N" - fi - - if [[ "$write_html" == "Y" ]]; then - writeInfo "${body//(\[P\])/($size)}" - fi - fi - - [[ "$final_pass" == "Y" ]] && break - - sleep 1 & - wait $! || : -done diff --git a/src/serial.sh b/src/serial.sh index 0a66e58..bf5e636 100644 --- a/src/serial.sh +++ b/src/serial.sh @@ -4,6 +4,7 @@ set -Eeuo pipefail # Docker environment variables : "${HOST_MAC:=""}" +: "${HOST_CPU:=""}" : "${HOST_DEBUG:=""}" : "${HOST_MODEL:=""}" : "${HOST_SERIAL:=""}" @@ -11,6 +12,7 @@ set -Eeuo pipefail # Sanitize variables HOST_MAC=$(strip "$HOST_MAC") +HOST_CPU=$(strip "$HOST_CPU") HOST_MODEL=$(strip "$HOST_MODEL") HOST_SERIAL=$(strip "$HOST_SERIAL") GUEST_SERIAL=$(strip "$GUEST_SERIAL") @@ -20,7 +22,6 @@ HOST_API_SOCKET="$QEMU_DIR/qemu-host-api.sock" HOST_AGENT_SOCKET="$QEMU_DIR/qemu-host-agent.sock" validateHostMac() { - local m if [ -z "$HOST_MAC" ]; then return 0 @@ -29,12 +30,35 @@ validateHostMac() { HOST_MAC="${HOST_MAC//-/:}" if [[ ${#HOST_MAC} == 12 ]]; then - m="$HOST_MAC" + local m="$HOST_MAC" HOST_MAC="${m:0:2}:${m:2:2}:${m:4:2}:${m:6:2}:${m:8:2}:${m:10:2}" fi if [[ ${#HOST_MAC} != 17 ]]; then - error "Invalid HOST_MAC address: '$HOST_MAC', should be 12 or 17 digits long!" && exit 28 + error "Invalid HOST_MAC address: '$HOST_MAC', should be 12 or 17 digits long!" + exit 28 + fi + + return 0 +} + +configureHostCpuName() { + + if [ -z "$HOST_CPU" ]; then + [[ "${CPU,,}" != "unknown" ]] && HOST_CPU="$CPU" + fi + + if [ -n "$HOST_CPU" ]; then + # qemu-host expects a comma-separated CPU description with empty family + # and suffix fields, not QEMU's -cpu syntax. + HOST_CPU="${HOST_CPU%%,*},," + else + HOST_CPU="QEMU, Virtual CPU," + if [ "$ARCH" == "amd64" ]; then + HOST_CPU+=" X86_64" + else + HOST_CPU+=" $ARCH" + fi fi return 0 @@ -141,6 +165,7 @@ configureSerialPorts() { } validateHostMac +configureHostCpuName buildHostArguments startHostBinary diff --git a/src/server.sh b/src/server.sh deleted file mode 100644 index 015b04b..0000000 --- a/src/server.sh +++ /dev/null @@ -1,186 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -: "${WEB_PORT:="5000"}" # Webserver port - -# Sanitize port variables -WEB_PORT=$(strip "$WEB_PORT") - -WEB_PID="/run/nginx.pid" -WSD_LOG="/var/log/websocketd.log" -WSD_COMMAND="$QEMU_DIR/status.cmd" -WSD_PID="$QEMU_DIR/websocketd.pid" -WSD_SOCKET="$QEMU_DIR/status-ws.sock" - -prepareWebFiles() { - - cp -r /var/www/* "$QEMU_DIR" || return 1 - rm -f -- "$WSD_PID" "$WSD_SOCKET" "$WSD_COMMAND" "$WEB_PID" "$WSD_LOG" || return 1 - - return 0 -} - -configureWebPorts() { - - if ! sed -i \ - -e "s|listen 5000 default_server;|listen $WEB_PORT default_server;|g" \ - /etc/nginx/sites-enabled/web.conf; then - error "Failed to configure webserver port!" - return 1 - fi - - return 0 -} - -configureIpv6Listen() { - - # Use one dual-stack listener when IPv6 is active, avoiding separate IPv4 - # and IPv6 sockets that can conflict on the same port. - if [ -f /proc/net/if_inet6 ] && [[ "$(cat /proc/sys/net/ipv6/conf/all/disable_ipv6 2>/dev/null)" != "1" ]]; then - - if ! sed -i \ - "s/listen $WEB_PORT default_server;/listen [::]:$WEB_PORT default_server ipv6only=off;/g" \ - /etc/nginx/sites-enabled/web.conf; then - error "Failed to configure IPv6 webserver listener!" - return 1 - fi - - fi - - return 0 -} - -configureNginx() { - - mkdir -p /etc/nginx/sites-enabled || return 1 - rm -f /etc/nginx/sites-enabled/default || return 1 - - # TODO: Use setfacl to grant www-data access to the Unix sockets - # and restore unprivileged nginx workers. - if ! sed -i \ - -e 's/^user .*/user root;/' \ - -e 's/^worker_processes.*/worker_processes 1;/' \ - /etc/nginx/nginx.conf; then - error "Failed to configure nginx!" - return 1 - fi - - if ! cp /etc/nginx/default.conf /etc/nginx/sites-enabled/web.conf; then - error "Failed to copy nginx config!" - return 1 - fi - - return 0 -} - -configureWebServer() { - - configureNginx || return 1 - configureWebPorts || return 1 - configureIpv6Listen || return 1 - - return 0 -} - -stopWebServer() { - - local pid - - if readPidFile pid "$WEB_PID"; then - pKill "$pid" 2 - - # Escalate only after the normal termination grace period; stale nginx - # processes would otherwise keep the configured web port occupied. - if isAlive "$pid"; then - kill -9 -- "$pid" 2>/dev/null || : - fi - fi - - rm -f -- "$WEB_PID" - return 0 -} - -startWebServer() { - - # Start webserver - nginx -e stderr || return 1 - - return 0 -} - -stopWebsocketServer() { - - local pid - - if readPidFile pid "$WSD_PID"; then - pKill "$pid" 2 - - if isAlive "$pid"; then - kill -9 -- "$pid" 2>/dev/null || : - fi - fi - - rm -f -- "$WSD_PID" "$WSD_SOCKET" - return 0 -} - -startWebsocketServer() { - - # Start websocket server - websocketd \ - --unixsocket="$WSD_SOCKET" \ - /run/socket.sh \ - >"$WSD_LOG" 2>&1 & - - local pid=$! - - if ! echo "$pid" > "$WSD_PID"; then - kill "$pid" 2>/dev/null || : - rm -f -- "$WSD_PID" - return 1 - fi - - local i - for (( i = 1; i <= 50; i++ )); do - - if ! isAlive "$pid"; then - rm -f -- "$WSD_PID" "$WSD_SOCKET" - [ -s "$WSD_LOG" ] && cat "$WSD_LOG" >&2 - error "Failed to start websocket server!" - return 1 - fi - - [ -S "$WSD_SOCKET" ] && return 0 - - sleep 0.1 - - done - - pKill "$pid" 2 - - if isAlive "$pid"; then - kill -9 -- "$pid" 2>/dev/null || : - fi - - rm -f -- "$WSD_PID" "$WSD_SOCKET" - [ -s "$WSD_LOG" ] && cat "$WSD_LOG" >&2 - error "Websocket server did not create its socket!" - return 1 -} - -prepareWebFiles - -html "Starting $APP for $ENGINE..." - -disabled "${WEB:-}" && return 0 - -configureWebServer - -if startWebServer && startWebsocketServer; then - return 0 -fi - -stopWebsocketServer || : -stopWebServer || : - -return 1 diff --git a/src/start.sh b/src/start.sh deleted file mode 100644 index 1b63c84..0000000 --- a/src/start.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -# You can override this hook to execute a script before startup! - -return 0 diff --git a/src/utils.sh b/src/utils.sh deleted file mode 100644 index 193f144..0000000 --- a/src/utils.sh +++ /dev/null @@ -1,662 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -# Helper functions - -info () { printf "%b%s%b" "\E[1;34m❯ \E[1;36m" "${1:-}" "\E[0m\n"; } -error () { printf "%b%s%b" "\E[1;31m❯ " "ERROR: ${1:-}" "\E[0m\n" >&2; } -warn () { printf "%b%s%b" "\E[1;31m❯ " "Warning: ${1:-}" "\E[0m\n" >&2; } - -app() { - - echo "Virtual DSM" - return 0 -} - -readPidFile() { - - local -n _pid="$1" - _pid="" - - if ! _pid=$(cat -- "$2" 2>/dev/null); then - _pid="" - return 1 - fi - - # Reject empty, zero, or nonnumeric pidfiles so cleanup can never signal an - # unintended process group. - if [[ ! "$_pid" =~ ^[1-9][0-9]*$ ]]; then - _pid="" - return 1 - fi - - return 0 -} - -hasFlag() { - - # Match a whitespace-delimited token in /proc/cpuinfo - grep -m1 '^flags[[:space:]]*:' /proc/cpuinfo | grep -Fqw -- "$1" - -} - -hasFeature() { - - # Match a whitespace-delimited token in /proc/cpuinfo - grep -m1 '^Features[[:space:]]*:' /proc/cpuinfo | grep -Fqw -- "$1" - -} - -isAmdCpu() { - - local vendor - vendor=$(awk -F ': *' '/^vendor_id/{print $2; exit}' /proc/cpuinfo) - - [[ "$vendor" == "AuthenticAMD" ]] -} - -getPciBus() { - - local machine="${1:-${MACHINE:-q35}}" - - if [ -n "${PCI_BUS:-}" ]; then - echo "$PCI_BUS" - return 0 - fi - - case "${machine,,}" in - pc|pc-i440fx*) echo "pci.0" ;; - *) echo "pcie.0" ;; - esac - - return 0 -} - -interactive() { - - # A TTY on stdin is insufficient when /dev/tty is unavailable; require both - # before enabling interactive console handling. - [ -t 0 ] && : 2>/dev/null /dev/tty - -} - -strip() { - - local value="${1:-}" - - # Remove surrounding whitespace - value="${value#"${value%%[![:space:]]*}"}" - value="${value%"${value##*[![:space:]]}"}" - - # Remove leading/trailing single/double quotes - value="${value%\"}" - value="${value#\"}" - value="${value%\'}" - value="${value#\'}" - - # Remove surrounding whitespace again - value="${value#"${value%%[![:space:]]*}"}" - value="${value%"${value##*[![:space:]]}"}" - - printf '%s' "$value" -} - -enabled() { - - local value - value=$(strip "${1:-}") - - case "${value,,}" in - y|yes|true|1|on|enable|enabled) return 0 ;; - *) return 1 ;; - esac -} - -disabled() { - - local value - value=$(strip "${1:-}") - - case "${value,,}" in - n|no|none|false|0|off|disable|disabled) return 0 ;; - *) return 1 ;; - esac -} - -isAlive() { - - local pid="$1" - [ -z "$pid" ] && return 1 - - if kill -0 "$pid" 2>/dev/null; then - return 0 - fi - - return 1 -} - -waitPid() { - - local pid="$1" - local timeout="${2:-10}" - local deadline=$((SECONDS + timeout)) - - while [ -n "$pid" ] && isAlive "$pid"; do - (( SECONDS >= deadline )) && return 1 - sleep 0.2 - done - - return 0 -} - -waitPidFile() { - - local pid - local file="$1" - local timeout="${2:-10}" - local deadline=$((SECONDS + timeout)) - - readPidFile pid "$file" || return 0 - - while [ -s "$file" ] && isAlive "$pid"; do - (( SECONDS >= deadline )) && return 1 - sleep 0.2 - done - - rm -f -- "$file" - return 0 -} - -pKill() { - - local pid="$1" - local timeout="${2:-10}" - - { kill -15 -- "$pid" || :; } 2>/dev/null - - if ! waitPid "$pid" "$timeout"; then - warn "Timed out while waiting for PID $pid" - fi - - return 0 -} - -fWait() { - - local name="$1" - local timeout="${2:-10}" - local deadline=$((SECONDS + timeout)) - - [ -z "$name" ] && return 0 - - while pgrep -f -l "$name" >/dev/null; do - if (( SECONDS >= deadline )); then - warn "Timed out while waiting for process: $name" - break - fi - - sleep 0.2 - done - - return 0 -} - -fKill() { - - local name="$1" - local timeout="${2:-10}" - - [ -z "$name" ] && return 0 - - { pkill -f "$name" || :; } 2>/dev/null - fWait "$name" "$timeout" - - return 0 -} - -sKill() { - - local pid - local file="$1" - - readPidFile pid "$file" || return 0 - - if isAlive "$pid"; then - { kill -15 -- "$pid" || :; } 2>/dev/null - fi - - return 0 -} - -mKill() { - - local timeout=10 - local files=("$@") - - for file in "${files[@]}"; do - sKill "$file" - done - - for file in "${files[@]}"; do - if ! waitPidFile "$file" "$timeout"; then - warn "Timed out while waiting for PID file: $file" - fi - done - - return 0 -} - -finiteMemoryLimit() { - - local limit="$1" - # cgroup v1 commonly reports this enormous sentinel for an unlimited memory - # limit; compare as decimal strings to avoid shell integer overflow. - local sentinel="4611686018427387904" - local i - - [[ "$limit" =~ ^[0-9]+$ ]] || return 1 - - (( ${#limit} < ${#sentinel} )) && return 0 - (( ${#limit} > ${#sentinel} )) && return 1 - - for (( i=0; i<${#sentinel}; i++ )); do - local left="${limit:i:1}" - local right="${sentinel:i:1}" - - (( left < right )) && return 0 - (( left > right )) && return 1 - done - - return 1 -} - -getMemoryInfo() { - - local host_total - local host_avail - local limit="" - local current="" - - host_total=$(free -b | awk '/^Mem:/ {print $2; exit}') - host_avail=$(free -b | awk '/^Mem:/ {print $7; exit}') - - RAM_TOTAL="$host_total" - RAM_AVAIL="$host_avail" - - if [ -r /sys/fs/cgroup/memory.max ] && [ -r /sys/fs/cgroup/memory.current ]; then - limit=$(< /sys/fs/cgroup/memory.max) - current=$(< /sys/fs/cgroup/memory.current) - elif [ -r /sys/fs/cgroup/memory/memory.limit_in_bytes ] && [ -r /sys/fs/cgroup/memory/memory.usage_in_bytes ]; then - limit=$(< /sys/fs/cgroup/memory/memory.limit_in_bytes) - current=$(< /sys/fs/cgroup/memory/memory.usage_in_bytes) - fi - - # Use the tighter of host availability and the container's remaining cgroup - # allowance so RAM sizing cannot exceed either boundary. - if finiteMemoryLimit "$limit" && [[ "$current" =~ ^[0-9]+$ ]]; then - (( limit < RAM_TOTAL )) && RAM_TOTAL="$limit" - - local available=$(( limit - current )) - (( available < 0 )) && available=0 - (( available < RAM_AVAIL )) && RAM_AVAIL="$available" - fi - - return 0 -} - -baseDir() { - - local path="${1%/}" - - [[ -z "$path" || "$path" == "/" ]] && { - echo "/" - return 0 - } - - path="${path#/}" - path="${path%%/*}" - - echo "/$path" - return 0 -} - -formatBytes() { - - local result - - if ! result=$(numfmt --to=iec --suffix=B "$1" | sed -r 's/([A-Z])/ \1/' | sed 's/ B/ bytes/g;'); then - return 1 - fi - - local unit="${result//[0-9. ]}" - result="${result//[a-zA-Z ]/}" - - if [[ "${2:-}" == "up" ]]; then - if [[ "$result" == *"."* ]]; then - result="${result%%.*}" - result=$((result+1)) - fi - else - if [[ "${2:-}" == "down" ]]; then - result="${result%%.*}" - fi - fi - - echo "$result $unit" - return 0 -} - -setOwner() { - - local file="$1" - local dir uid gid - - [ ! -f "$file" ] && return 1 - - # Match generated files to the owner of their bind-mounted parent directory - # instead of assuming a fixed container or host UID. - dir=$(dirname -- "$file") - uid=$(stat -c '%u' "$dir") || return 1 - gid=$(stat -c '%g' "$dir") || return 1 - - chown "$uid:$gid" "$file" || return 1 - - return 0 -} - -makeDir() { - - local path="$1" - local dir uid gid - - [ -d "$path" ] && return 0 - mkdir -p "$path" || return 1 - - dir=$(dirname -- "$path") - - if ! uid=$(stat -c '%u' "$dir") || ! gid=$(stat -c '%g' "$dir"); then - warn "failed to determine the owner for \"$path\"." - return 0 - fi - - if ! chown "$uid:$gid" "$path"; then - warn "failed to set the owner for \"$path\"." - return 0 - fi - - return 0 -} - -stateFile() { - - local name="$1" - local prefix="${2:-$PROCESS}" - - [[ "$name" == */* ]] && printf '%s\n' "$name" && return 0 - - printf '%s/%s.%s\n' "$STORAGE" "$prefix" "$name" - return 0 -} - -writeFile() { - - local txt="$1" - local path="$2" - - if ! printf '%s\n' "$txt" > "$path"; then - error "Failed to write file \"$path\" !" - return 1 - fi - - if ! setOwner "$path"; then - warn "failed to set the owner for \"$path\"." - fi - - return 0 -} - -writeAtomic() { - - local path="$1" - local content="$2" - # Use a per-process temporary file and rename so readers see either the old - # complete value or the new complete value. - local tmp="${path}.${BASHPID}.tmp" - - if ! printf '%s\n' "$content" > "$tmp"; then - rm -f -- "$tmp" - return 1 - fi - - if ! mv -f -- "$tmp" "$path"; then - rm -f -- "$tmp" - return 1 - fi - - return 0 -} - -readFile() { - - local path="$1" - local value - - [ -s "$path" ] || return 0 - - value=$(<"$path") || return 1 - value="${value//[![:print:]]/}" - - printf '%s\n' "$value" - return 0 -} - -writeState() { - - local name="$1" - local value="$2" - local prefix="${3:-$PROCESS}" - local path - - [ -z "$value" ] && return 0 - - path=$(stateFile "$name" "$prefix") || return 1 - writeFile "$value" "$path" - - return $? -} - -readState() { - - local name="$1" - local prefix="${2:-$PROCESS}" - local path - - path=$(stateFile "$name" "$prefix") || return 1 - readFile "$path" - - return $? -} - -restoreState() { - - local var="$1" - local name="$2" - local force="${3:-N}" - local prefix="${4:-$PROCESS}" - local value - - # Persistent state fills only unset variables unless force is requested, - # preserving explicit environment overrides. - if ! enabled "$force"; then - [ -z "${!var:-}" ] || return 0 - fi - - value=$(readState "$name" "$prefix") || return 1 - [ -n "$value" ] || return 0 - - printf -v "$var" '%s' "$value" || return 1 - return 0 -} - -escape () { - - local s=${1//&/\&} - s=${s///\>} - s=${s//'"'/\"} - - printf -- %s "$s" - - return 0 -} - -escapeXML() { - - printf '%s' "$1" | sed \ - -e 's/&/\&/g' \ - -e 's//\>/g' \ - -e 's/"/\"/g' \ - -e "s/'/\'/g" - - return 0 -} - -html() { - - local title - local body - local script="${2:-}" - local footer - - title=$(escape "$APP") - title="$title" - footer=$(escape "$FOOTER1") - - body=$(escape "$1") - if [[ "$body" == *"..." ]]; then - body="

${body/.../}

" - fi - - local HTML - HTML=$(<"$TEMPLATE") - HTML="${HTML/\[1\]/$title}" - HTML="${HTML/\[2\]/$script}" - HTML="${HTML/\[3\]/$body}" - HTML="${HTML/\[4\]/$footer}" - HTML="${HTML/\[5\]/$FOOTER2}" - - # Publish both the full page and websocket fragment atomically because nginx - # and websocketd may read them concurrently. - writeAtomic "$PAGE" "$HTML" || return 1 - writeAtomic "$INFO" "$body" || return 1 - - return 0 -} - -cpu() { - - local ret - local cpu="" - - ret=$(lscpu) - - if grep -qi "model name" <<< "$ret"; then - cpu=$(echo "$ret" | grep -m 1 -i 'model name' | cut -f 2 -d ":" | awk '{$1=$1}1' | sed 's# @.*##g' | sed s/"(R)"//g | sed 's/[^[:alnum:] ]\+/ /g' | sed 's/ */ /g') - fi - - if [ -z "${cpu// /}" ] && grep -qi "model:" <<< "$ret"; then - cpu=$(echo "$ret" | grep -m 1 -i 'model:' | cut -f 2 -d ":" | awk '{$1=$1}1' | sed 's# @.*##g' | sed s/"(R)"//g | sed 's/[^[:alnum:] ]\+/ /g' | sed 's/ */ /g') - fi - - cpu="${cpu// CPU/}" - cpu="${cpu// [0-9][0-9][0-9] Core}" - cpu="${cpu// [0-9][0-9] Core}" - cpu="${cpu// [0-9] Core}" - cpu="${cpu//[0-9][0-9]th Gen }" - cpu="${cpu//[0-9]th Gen }" - cpu="${cpu// Processor/}" - cpu="${cpu// Quad core/}" - cpu="${cpu// Dual core/}" - cpu="${cpu// Octa core/}" - cpu="${cpu// Hexa core/}" - cpu="${cpu// Core TM/ Core}" - cpu="${cpu// with Radeon Graphics/}" - cpu="${cpu// with Radeon Vega Graphics/}" - cpu="${cpu// with Radeon Vega Mobile Gfx/}" - cpu="${cpu// w Radeon [0-9][0-9][0-9]M Graphics/}" - - [ -z "${cpu// /}" ] && cpu="Unknown" - - echo "$cpu" - return 0 -} - -getCountry() { - - local url=$1 - local query=$2 - local json result - - { json=$(curl -m 5 -H "Accept: application/json" -sfk "$url"); local rc=$?; } || : - (( rc != 0 )) && return 0 - - { result=$(echo "$json" | jq -r "$query" 2> /dev/null); rc=$?; } || : - (( rc != 0 )) && return 0 - - [[ ${#result} -ne 2 ]] && return 0 - [[ "${result^^}" == "XX" ]] && return 0 - - COUNTRY="${result^^}" - - return 0 -} - -setCountry() { - - [[ "${TZ,,}" == "asia/harbin" ]] && COUNTRY="CN" - [[ "${TZ,,}" == "asia/beijing" ]] && COUNTRY="CN" - [[ "${TZ,,}" == "asia/urumqi" ]] && COUNTRY="CN" - [[ "${TZ,,}" == "asia/kashgar" ]] && COUNTRY="CN" - [[ "${TZ,,}" == "asia/shanghai" ]] && COUNTRY="CN" - [[ "${TZ,,}" == "asia/chongqing" ]] && COUNTRY="CN" - - # Country detection is best-effort and tries independent services in order; - # failure leaves mirror selection at its global default. - [ -z "$COUNTRY" ] && getCountry "https://api.ipapi.is" ".location.country_code" - [ -z "$COUNTRY" ] && getCountry "https://ifconfig.co/json" ".country_iso" - [ -z "$COUNTRY" ] && getCountry "https://api.ip2location.io" ".country_code" - [ -z "$COUNTRY" ] && getCountry "https://ipinfo.io/json" ".country" - [ -z "$COUNTRY" ] && getCountry "https://api.ipquery.io/?format=json" ".location.country_code" - [ -z "$COUNTRY" ] && getCountry "https://api.myip.com" ".cc" - - return 0 -} - -addPackage() { - - local pkg=$1 - local desc=$2 - - if apt-mark showinstall | grep -qx "$pkg"; then - return 0 - fi - - MSG="Installing $desc..." - info "$MSG" && html "$MSG" - - [ -z "$COUNTRY" ] && setCountry - - # Use a mainland mirror only for on-demand package installation, avoiding - # slow or inaccessible Debian endpoints in that region. - if [[ "${COUNTRY^^}" == "CN" ]]; then - sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/debian.sources - fi - - DEBIAN_FRONTEND=noninteractive apt-get -qq update || return 1 - DEBIAN_FRONTEND=noninteractive apt-get -qq --no-install-recommends -y install "$pkg" > /dev/null || return 1 - - return 0 -} - -return 0 diff --git a/web/conf/nginx.conf b/web/conf/nginx.conf index 1d3e6af..541a47e 100644 --- a/web/conf/nginx.conf +++ b/web/conf/nginx.conf @@ -1,4 +1,5 @@ server { + listen 5000 default_server; autoindex on; @@ -9,6 +10,9 @@ server { error_log /dev/null; access_log /dev/null; + auth_basic off; + auth_basic_user_file /etc/nginx/.htpasswd; + include /etc/nginx/mime.types; gzip on; diff --git a/web/css/style.css b/web/css/style.css deleted file mode 100644 index f18dc69..0000000 --- a/web/css/style.css +++ /dev/null @@ -1,229 +0,0 @@ -body { - color: white; - background-color: #125bdb; - font-smoothing: antialiased; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - font-family: Verdana, Geneva, sans-serif; -} - -#info { - text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.25); -} - -#content { - text-align: center; - padding: 20px; - margin-top: 50px; -} - -#progress { - width: 0; - min-width: 250px; - max-width: 80vw; - height: 15px; - overflow: hidden; - margin: 20px auto 0; - border-radius: 999px; - background: rgba(0, 0, 0, 0.17); - box-shadow: - inset 0 1px 2px rgba(0, 0, 0, 0.22), - 0 1px 0 rgba(255, 255, 255, 0.08); -} - -#progress[hidden] { - display: none; -} - -#progress-fill { - position: relative; - width: 0; - height: 100%; - overflow: hidden; - background: rgba(255, 255, 255, 0.94); - transition: width 0.25s ease; -} - -#progress-fill:after { - content: ""; - position: absolute; - top: 0; - bottom: 0; - left: -40%; - width: 34%; - background: linear-gradient( - 90deg, - rgba(255, 255, 255, 0), - rgba(189, 227, 255, 0.15) 25%, - rgba(255, 255, 255, 0.70) 50%, - rgba(189, 227, 255, 0.15) 75%, - rgba(255, 255, 255, 0) - ); - animation: progress-sheen 1.9s ease-in-out infinite; -} - -@keyframes progress-sheen { - 0% { - left: -40%; - opacity: 0; - } - - 12% { - opacity: 1; - } - - 60%, - 100% { - left: 112%; - opacity: 1; - } -} - -footer { - width: 98%; - position: fixed; - bottom: 0px; - height: 40px; - text-align: center; - color: #0c8aeb; - text-shadow: 0 0 1px #0c8aeb; -} - -#empty { - height: 40px; - /* Same height as footer */ -} - -a, -a:hover, -a:active, -a:visited { - color: white; -} - -footer a:link, -footer a:visited, -footer a:active { - color: #0c8aeb; -} - -footer a:hover { - color: #73e6ff; -} - -.loading:after { - content: " ."; - animation: dots 1s steps(5, end) infinite; -} - -@keyframes dots { - - 0%, - 20% { - color: rgba(0, 0, 0, 0); - text-shadow: 0.25em 0 0 rgba(0, 0, 0, 0), 0.5em 0 0 rgba(0, 0, 0, 0); - } - - 40% { - color: white; - text-shadow: 0.25em 0 0 rgba(0, 0, 0, 0), 0.5em 0 0 rgba(0, 0, 0, 0); - } - - 60% { - text-shadow: 0.25em 0 0 white, 0.5em 0 0 rgba(0, 0, 0, 0); - } - - 80%, - 100% { - text-shadow: 0.25em 0 0 white, 0.5em 0 0 white; - } -} - -.spinner_LWk7 { - animation: spinner_GWy6 1.2s linear infinite, spinner_BNNO 1.2s linear infinite -} - -.spinner_yOMU { - animation: spinner_GWy6 1.2s linear infinite, spinner_pVqn 1.2s linear infinite; - animation-delay: .15s -} - -.spinner_KS4S { - animation: spinner_GWy6 1.2s linear infinite, spinner_6uKB 1.2s linear infinite; - animation-delay: .3s -} - -.spinner_zVee { - animation: spinner_GWy6 1.2s linear infinite, spinner_Qw4x 1.2s linear infinite; - animation-delay: .45s -} - -@keyframes spinner_GWy6 { - - 0%, - 50% { - width: 9px; - height: 9px - } - - 10% { - width: 11px; - height: 11px - } -} - -@keyframes spinner_BNNO { - - 0%, - 50% { - x: 1.5px; - y: 1.5px - } - - 10% { - x: .5px; - y: .5px - } -} - -@keyframes spinner_pVqn { - - 0%, - 50% { - x: 13.5px; - y: 1.5px - } - - 10% { - x: 12.5px; - y: .5px - } -} - -@keyframes spinner_6uKB { - - 0%, - 50% { - x: 13.5px; - y: 13.5px - } - - 10% { - x: 12.5px; - y: 12.5px - } -} - -@keyframes spinner_Qw4x { - - 0%, - 50% { - x: 1.5px; - y: 13.5px - } - - 10% { - x: .5px; - y: 12.5px - } -} diff --git a/web/img/favicon.svg b/web/img/favicon.svg deleted file mode 100644 index 9cbb85f..0000000 --- a/web/img/favicon.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/web/index.html b/web/index.html deleted file mode 100644 index 815c344..0000000 --- a/web/index.html +++ /dev/null @@ -1,37 +0,0 @@ - - - - - [1] - - - - - [2] - - - -
-
- - - - - - -

[3]

- -
-
-
-
- [4]
- [5] -
-
- - - -