#!/bin/sh # Copyright (c) 2023 Cloudnet Inc & AUTHORS All rights reserved. # Use of this source code is governed by a BSD-style # license that can be found in the LICENSE file. # # This script detects the current operating system, and installs # Cloudnet according to that OS's conventions. set -eu # All the code is wrapped in a main function that gets called at the # bottom of the file, so that a truncated partial download doesn't end # up executing half a script. # # macOS helpers deliberately use only system tools. Python is used by the # isolated tests, not by the downloaded installer. _log() { printf '%s\n' "->> $*" >&2 } _tool() { "$@" } _now() { _tool /bin/date +%s } _compare_versions() { /usr/bin/awk -v left="$1" -v right="$2" 'BEGIN { n = split(left, a, "."); m = split(right, b, "."); if (n < 1 || n > 4 || m < 1 || m > 4) exit 65; for (i = 1; i <= n; i++) if (a[i] !~ /^[0-9]+$/ || length(a[i]) > 9) exit 65; for (i = 1; i <= m; i++) if (b[i] !~ /^[0-9]+$/ || length(b[i]) > 9) exit 65; for (i = 1; i <= 4; i++) { if (a[i]+0 > b[i]+0) { print 1; exit } if (a[i]+0 < b[i]+0) { print -1; exit } } print 0 }' } _business_version() { printf '%s\n' "$1" | /usr/bin/awk '{ gsub(/[-+]/, "."); n = split($0, a, "."); if (n != 4) exit 65; for (i = 1; i <= n; i++) if (a[i] !~ /^[0-9]+$/ || length(a[i]) > 9) exit 65; printf "%.0f.%.0f.%.0f.%.0f\n", a[1], a[2], a[3], a[4] }' } _plist_value() { _tool /usr/libexec/PlistBuddy -c "Print :$2" "$1" 2>/dev/null } _desktop_context() { cn_euid=$(_tool /usr/bin/id -u) || return 65 if [ "$cn_euid" -eq 0 ]; then cn_user=${SUDO_USER:-} case "$cn_user" in '' | root) _log "Run from the desktop user's terminal (a valid SUDO_USER is required)."; return 64 ;; esac cn_uid=$(_tool /usr/bin/id -u "$cn_user") || return 64 [ "$cn_uid" -ne 0 ] && [ "$cn_uid" = "${SUDO_UID:-}" ] || return 64 cn_console=$(_tool /usr/bin/stat -f %u /dev/console) || return 64 [ "$cn_console" = "$cn_uid" ] || { _log "SUDO_USER is not the active desktop user."; return 64; } cn_home=$(_tool /usr/bin/dscl . -read "/Users/$cn_user" NFSHomeDirectory | sed 's/^NFSHomeDirectory: //') || return 64 else cn_user=$(_tool /usr/bin/id -un) || return 65 cn_uid=$cn_euid cn_home=${HOME:-} fi case "$cn_home" in /*) ;; *) _log "Cannot resolve the desktop user's home directory."; return 64 ;; esac [ -d "$cn_home" ] || return 64 cn_receipt_dir="$cn_home/Library/Application Support/world.cloudnet.client/installer" cn_receipt="$cn_receipt_dir/receipt.plist" cn_authorized=0 } _user() { if [ "$cn_euid" -eq 0 ]; then _tool /bin/launchctl asuser "$cn_uid" /usr/bin/sudo -H -u "$cn_user" -- "$@" else _tool "$@" fi } _root() { [ "$cn_authorized" -eq 1 ] || return 77 if [ "$cn_euid" -eq 0 ]; then _tool "$@"; else _tool /usr/bin/sudo -n -- "$@"; fi } _authorize() { if [ "$cn_euid" -ne 0 ] && ! _tool /usr/bin/sudo -v; then _log "Administrator authorization was cancelled or denied; the app and service are unchanged." return 77 fi cn_authorized=1 } # Bound only the process group created for this one command. In particular, # never kill by process name or send signals to the network daemon. _bounded() { cn_bound_role=$1 shift # shellcheck disable=SC2016 # Perl code, not a shell interpolation. "_$cn_bound_role" /usr/bin/perl -e ' use strict; use POSIX qw(WNOHANG setpgid); use Time::HiRes qw(time sleep); my $seconds = shift @ARGV; my $pid = fork(); defined $pid or exit 71; if (!$pid) { setpgid(0, 0); exec @ARGV; exit 71 } eval { setpgid($pid, $pid) }; my $stop = sub { kill "TERM", -$pid; sleep 0.2; kill "KILL", -$pid; waitpid($pid, 0) }; $SIG{INT} = $SIG{TERM} = $SIG{HUP} = sub { $stop->(); exit 130 }; my $end = time() + $seconds; while (waitpid($pid, WNOHANG) == 0) { if (time() >= $end) { $stop->(); exit 124 } sleep 0.05; } exit(($? & 127) ? 128 + ($? & 127) : $? >> 8); ' "$@" } _curl() { _user /usr/bin/curl --proto '=https' "$@" } _private_dir() { [ ! -L "$1" ] || { _log "Refusing a symlink at a private installer directory."; return 73; } _user /bin/mkdir -p "$1" && _user /bin/chmod 0700 "$1" } _lock() { cn_lock="$cn_cache/installer.lock" [ ! -L "$cn_lock" ] || return 73 if [ -e "$cn_lock" ] && [ ! -f "$cn_lock" ]; then return 73; fi _user /usr/bin/touch "$cn_lock" && _user /bin/chmod 0600 "$cn_lock" || return 73 exec 9< "$cn_lock" || return 73 # The inherited open-file description holds flock after Perl exits. # Never unlink it: that would allow concurrent callers to lock two inodes. # The kernel also releases this lock after an interrupted installer exits. cn_lock_rc=0 _tool /usr/bin/perl -e 'use Fcntl qw(:flock); use Errno qw(EWOULDBLOCK EAGAIN); flock(STDIN, LOCK_EX | LOCK_NB) or exit(($! == EWOULDBLOCK || $! == EAGAIN) ? 75 : 73);' <&9 || cn_lock_rc=$? if [ "$cn_lock_rc" -ne 0 ]; then exec 9<&- _log "Another installer is running or its lock is unavailable; try again later." return "$cn_lock_rc" fi cn_lock_owned=1 } _size() { _tool /usr/bin/stat -f %z "$1" } _sha() { _tool /usr/bin/shasum -a 256 "$1" | /usr/bin/awk '{print $1}' } _file_matches() { [ -f "$1" ] && [ ! -L "$1" ] || return 65 [ "$(_size "$1")" = "$cn_size" ] && [ "$(_sha "$1")" = "$cn_sha" ] } _fetch_metadata() { cn_fetch_code=$(_curl --silent --show-error --connect-timeout 10 --max-time 30 \ --max-filesize 262144 --header 'Cache-Control: no-cache' \ --output "$2" --write-out '%{http_code}' "$1") || return 69 [ "$cn_fetch_code" = 200 ] && [ "$(_size "$2")" -le 262144 ] || return 65 } _xml_value() { _tool /usr/bin/xmllint --nonet --xpath "string($2)" "$1" 2>/dev/null } _parse_appcast() { # Do not process DTDs or external entities, even when the feed is fetched # from the official host. XPath also checks the Sparkle namespace URI. if /usr/bin/grep -Eiq '/dev/null || return 65 cn_ns='http://www.andymatuschak.org/xml-namespaces/sparkle' cn_items="/rss/channel/item[enclosure/@*[local-name()='os' and namespace-uri()='$cn_ns']='macos']" cn_item_count=$(_xml_value "$1" "count($cn_items)") || return 65 case "$cn_item_count" in '' | *[!0-9]*) return 65 ;; esac [ "$cn_item_count" -ge 1 ] && [ "$cn_item_count" -le 100 ] || return 65 cn_release='' cn_native='' cn_url='' cn_size='' cn_minimum='' cn_item_i=1 while [ "$cn_item_i" -le "$cn_item_count" ]; do cn_item="($cn_items)[$cn_item_i]" cn_title=$(_xml_value "$1" "$cn_item/title") || return 65 cn_item_release=$(_business_version "$cn_title") || return 65 [ "$cn_title" = "$cn_item_release" ] || return 65 cn_item_native=$(_xml_value "$1" "$cn_item/enclosure/@*[local-name()='version' and namespace-uri()='$cn_ns']") || return 65 cn_item_url=$(_xml_value "$1" "$cn_item/enclosure/@url") || return 65 cn_item_size=$(_xml_value "$1" "$cn_item/enclosure/@length") || return 65 cn_item_min=$(_xml_value "$1" "$cn_item/*[local-name()='minimumSystemVersion' and namespace-uri()='$cn_ns']") || return 65 [ "$cn_item_native" = "${cn_item_release%.*}${cn_item_release##*.}" ] || return 65 [ "$cn_item_url" = "https://pkgs.cloudnet.world/stable/macos/CloudNet_v$cn_item_release.dmg" ] || return 65 case "$cn_item_size" in '' | 0* | *[!0-9]*) return 65 ;; esac [ "${#cn_item_size}" -le 10 ] && [ "$cn_item_size" -le 2147483647 ] || return 65 _compare_versions "$cn_item_min" 12.0 >/dev/null || return 65 if [ -z "$cn_release" ]; then cn_order=1; else cn_order=$(_compare_versions "$cn_item_release" "$cn_release") || return 65; fi [ "$cn_order" -ne 0 ] || { _log "Ambiguous duplicate macOS release in appcast."; return 65; } if [ "$cn_order" -gt 0 ]; then cn_release=$cn_item_release cn_native=$cn_item_native cn_url=$cn_item_url cn_size=$cn_item_size cn_minimum=$cn_item_min fi cn_item_i=$((cn_item_i + 1)) done [ "$(_compare_versions "$cn_release" 1.36.2.23)" -ge 0 ] || { _log "The macOS online installer requires desktop release 1.36.2.23 or newer." return 65 } [ "$(_compare_versions "$cn_os_version" "$cn_minimum")" -ge 0 ] || { _log "This release requires macOS $cn_minimum or newer." return 65 } } _metadata() { cn_meta_suffix= if [ "$1" -gt 1 ]; then cn_meta_suffix="?refresh=$(_now)-$$"; fi _fetch_metadata "https://pkgs.cloudnet.world/stable/appcast.xml$cn_meta_suffix" "$cn_work/appcast.xml" || return $? _parse_appcast "$cn_work/appcast.xml" || return $? _fetch_metadata "$cn_url.sum$cn_meta_suffix" "$cn_work/package.sum" || return $? cn_sha=$(/usr/bin/awk ' NF { if (++n != 1 || NF > 2 || length($1) != 64 || $1 !~ /^[0-9a-fA-F]+$/) exit 65; hash=tolower($1) } END { if (n != 1) exit 65; print hash } ' "$cn_work/package.sum") || return 65 cn_part="$cn_cache/$cn_sha.dmg.part" cn_dmg="$cn_cache/$cn_sha.dmg" } # Read the terminal through stderr: stdin is the script under `curl | sh`. _progress_columns() { cn_columns=$(_tool /bin/stty size <&2 2>/dev/null | /usr/bin/awk '{print $2}') || cn_columns= case "$cn_columns" in '' | *[!0-9]* | 0) cn_columns=80 ;; esac [ "$cn_columns" -le 240 ] || cn_columns=240 printf '%s\n' "$cn_columns" } _progress_watch() { # Replace this child shell so getppid tracks the installer itself. Do not # retain its lock fd, and exit if the installer disappears unexpectedly. # shellcheck disable=SC2016 # Perl code, not a shell interpolation. exec /usr/bin/perl -e ' use strict; use warnings; use Time::HiRes qw(time sleep); my ($file, $total, $offset, $columns, $limit) = @ARGV; my $parent = getppid(); exit 0 if $parent <= 1; my $started = time(); my $end = $started + $limit + 1; sub bytes { my $value = shift; my @units = qw(B KiB MiB GiB TiB); my $i = 0; while ($value >= 1024 && $i < $#units) { $value /= 1024; $i++ } return sprintf(($i == 0 || $value >= 100) ? "%.0f%s" : "%.1f%s", $value, $units[$i]); } sub draw { my $current = (-f $file && !-l $file) ? (stat($file))[7] : 0; $current = 0 unless defined $current; $current = $total if $current > $total; my $elapsed = time() - $started; $elapsed = 1 if $elapsed < 1; my $speed = ($current - $offset) / $elapsed; $speed = 0 if $speed < 0; my $stats = sprintf("%5.1f%% %s/%s %s/s", $current * 100 / $total, bytes($current), bytes($total), bytes($speed)); $stats = sprintf("%5.1f%% %s/%s", $current * 100 / $total, bytes($current), bytes($total)) if $columns < length($stats) + 13; $stats = sprintf("%5.1f%%", $current * 100 / $total) if $columns < length($stats) + 13; my $width = $columns - length($stats) - 4; $width = 1 if $width < 1; my $filled = int($width * $current / $total); printf STDERR "\r[%s%s] %s\e[K", "#" x $filled, "-" x ($width - $filled), $stats; } $SIG{HUP} = $SIG{INT} = $SIG{TERM} = sub { draw(); print STDERR "\n"; exit 0 }; while (getppid() == $parent && kill(0, $parent) && time() < $end) { draw(); sleep 0.2 } print STDERR "\n"; ' "$cn_part" "$cn_size" "$cn_offset" "$2" "$1" } _progress_stop() { if [ -n "${cn_progress_pid:-}" ]; then kill "$cn_progress_pid" 2>/dev/null || true wait "$cn_progress_pid" 2>/dev/null || true cn_progress_pid= fi } _progress_start() { cn_progress_pid= [ -t 2 ] || return 0 cn_progress_cols=$(_progress_columns) _progress_watch "$1" "$cn_progress_cols" /dev/null 9<&- & cn_progress_pid=$! } # curl may write headers for a CONNECT response before the final response. # Reset on every HTTP status line and validate only the final representation. _valid_headers() { /usr/bin/awk -v status="$2" -v offset="$3" -v total="$cn_size" ' /^HTTP\// { length_value=""; range_value=""; count_l=0; count_r=0 } { sub(/\r$/, ""); p=index($0, ":"); name=tolower(substr($0, 1, p-1)); value=substr($0, p+1); sub(/^[ \t]+/, "", value); sub(/[ \t]+$/, "", value); if (name=="content-length") { length_value=value; count_l++ } if (name=="content-range") { range_value=value; count_r++ } } END { if (count_l!=1 || length_value !~ /^[0-9]+$/) exit 65; if (status=="200") { if (offset!=0 || length_value+0!=total+0 || count_r) exit 65; exit } if (status!="206" || count_r!=1 || range_value !~ /^bytes [0-9]+-[0-9]+\/[0-9]+$/) exit 65; sub(/^bytes /, "", range_value); split(range_value, r, /[-\/]/); if (r[1]+0!=offset+0 || r[2]+0!=total-1 || r[3]+0!=total+0 || length_value+0!=total-offset) exit 65; } ' "$1" } # Return codes are an internal protocol: 65 = bad representation (refresh # metadata), 69 = transport failure (keep a valid partial file), 70 = start # again without Range. These codes are not source-line numbers. _transfer() { cn_offset=0 [ ! -L "$cn_part" ] || return 65 if [ -f "$cn_part" ]; then cn_offset=$(_size "$cn_part") || return 65; fi if [ "$cn_offset" -gt 0 ]; then _log "Resuming the DMG download from byte $cn_offset of $cn_size." else _log "Downloading the DMG ($cn_size bytes)." fi _progress_start "$1" cn_transfer_rc=0 cn_http=$(_curl --silent --show-error --connect-timeout 10 --max-time "$1" \ --speed-time 30 --speed-limit 1 --max-filesize "$cn_size" --continue-at - --dump-header "$cn_work/headers" \ --output "$cn_part" --write-out '%{http_code}' "$cn_url?sha256=$cn_sha" 2>"$cn_work/curl-errors") || cn_transfer_rc=$? _progress_stop _tool /bin/cat "$cn_work/curl-errors" >&2 || true case "$cn_http" in 416) return 70 ;; 200) [ "$cn_offset" -eq 0 ] || return 70 _valid_headers "$cn_work/headers" 200 0 || return 65 ;; 206) _valid_headers "$cn_work/headers" 206 "$cn_offset" || return 65 ;; *) return 69 ;; esac [ "$cn_transfer_rc" -eq 0 ] || return 69 _file_matches "$cn_part" || return 65 } _download_round() { if _file_matches "$cn_dmg"; then _log "Using the cached DMG; size and SHA-256 verified." return 0 fi [ ! -L "$cn_dmg" ] && [ ! -L "$cn_part" ] || return 65 _user /bin/rm -f "$cn_dmg" || return 73 if _file_matches "$cn_part"; then _log "Using the completed partial download; size and SHA-256 verified." _user /bin/mv "$cn_part" "$cn_dmg"; return $? fi if [ -f "$cn_part" ] && [ "$(_size "$cn_part")" -ge "$cn_size" ]; then _user /bin/rm "$cn_part" || return 73 fi cn_dl_end=$(($(_now) + 600)) cn_dl_attempt=1 cn_dl_clean=0 while [ "$cn_dl_attempt" -le 3 ]; do cn_dl_remaining=$((cn_dl_end - $(_now))) [ "$cn_dl_remaining" -gt 0 ] || return 69 cn_dl_result=0 _transfer "$cn_dl_remaining" || cn_dl_result=$? case "$cn_dl_result" in 0) _user /bin/mv "$cn_part" "$cn_dmg"; return $? ;; 65 | 70) _user /bin/rm -f "$cn_part" || return 73 [ "$cn_dl_clean" -eq 0 ] || return 65 cn_dl_clean=1 _log "Discarding an invalid partial download; retrying from byte zero." ;; 69) _log "Download interrupted; retrying the retained partial download." ;; *) return "$cn_dl_result" ;; esac cn_dl_attempt=$((cn_dl_attempt + 1)) done if [ "$cn_dl_clean" -eq 1 ]; then return 65; else return 69; fi } _download() { cn_dl_round=1 while [ "$cn_dl_round" -le 2 ]; do if [ "$cn_dl_round" -gt 1 ]; then _metadata "$cn_dl_round" || { _log "Cannot validate release metadata."; return 65; } fi _check_downgrade || return $? cn_download_rc=0 _download_round || cn_download_rc=$? [ "$cn_download_rc" -ne 0 ] || return 0 if [ "$cn_download_rc" -ne 65 ] || [ "$cn_dl_round" -eq 2 ]; then return "$cn_download_rc"; fi _log "Integrity verification failed; refreshing metadata for one final round." _user /bin/rm -f "$cn_part" "$cn_dmg" || return 73 cn_dl_round=$((cn_dl_round + 1)) done } _app_info() { [ -d "$1" ] && [ ! -L "$1" ] || return 66 [ "$(_plist_value "$1/Contents/Info.plist" CFBundleIdentifier)" = world.cloudnet.client ] || return 65 [ "$(_plist_value "$1/Contents/Info.plist" CFBundleExecutable)" = CloudNet ] || return 65 [ -x "$1/Contents/MacOS/CloudNet" ] || return 66 } _signature_info() { _tool /usr/bin/codesign --display --verbose=4 "$1" 2>&1 } _verify_app() { _app_info "$1" || return $? [ "$(_plist_value "$1/Contents/Info.plist" CFBundleShortVersionString)" = "$cn_native" ] || return 65 [ "$(_plist_value "$1/Contents/Info.plist" CFBundleVersion)" = "$cn_native" ] || return 65 cn_app_min=$(_plist_value "$1/Contents/Info.plist" LSMinimumSystemVersion) || return 65 [ "$(_compare_versions "$cn_app_min" 12.0)" -ge 0 ] || return 65 [ "$(_compare_versions "$cn_os_version" "$cn_app_min")" -ge 0 ] || return 65 _tool /usr/bin/codesign --verify --deep --strict "$1" >/dev/null 2>&1 || return 65 cn_sig=$(_signature_info "$1") || return 65 printf '%s\n' "$cn_sig" | /usr/bin/grep -qx 'TeamIdentifier=66B92BJL8F' || return 65 for cn_resource in cnet cloudnet cloudnetd cloudnetd.plist; do [ -f "$1/Contents/Resources/$cn_resource" ] && [ -r "$1/Contents/Resources/$cn_resource" ] && [ ! -L "$1/Contents/Resources/$cn_resource" ] || return 66 done [ -x "$1/Contents/Resources/cloudnet" ] && [ -x "$1/Contents/Resources/cloudnetd" ] || return 66 [ "$(_plist_value "$1/Contents/Resources/cloudnetd.plist" Label)" = world.cloudnet.client.cloudnetd ] || return 65 [ "$(_plist_value "$1/Contents/Resources/cloudnetd.plist" ProgramArguments:0)" = /usr/local/cloudnet/bin/cloudnetd ] || return 65 } _cdhash() { _signature_info "$1" | /usr/bin/awk -F= '/^CDHash=/ { print $2; exit }' } _receipt_identity() { [ -f "$cn_receipt" ] && [ ! -L "$cn_receipt" ] || return 65 [ "$(_plist_value "$cn_receipt" AppPath)" = "$cn_app" ] || return 65 [ "$(_plist_value "$cn_receipt" NativeVersion)" = "$(_plist_value "$cn_app/Contents/Info.plist" CFBundleShortVersionString)" ] || return 65 cn_receipt_cd=$(_plist_value "$cn_receipt" CDHash) || return 65 [ -n "$cn_receipt_cd" ] && [ "$cn_receipt_cd" = "$(_cdhash "$cn_app")" ] } _check_downgrade() { [ -e "$cn_app" ] || return 0 _app_info "$cn_app" || { _log "The existing app cannot be identified safely; repair or move it manually."; return 65; } cn_installed_native=$(_plist_value "$cn_app/Contents/Info.plist" CFBundleShortVersionString) || return 65 if _receipt_identity; then cn_installed_release=$(_plist_value "$cn_receipt" ReleaseVersion) || return 65 cn_installed_release=$(_business_version "$cn_installed_release") || return 65 [ "${cn_installed_release%.*}${cn_installed_release##*.}" = "$cn_installed_native" ] || return 65 cn_version_order=$(_compare_versions "$cn_release" "$cn_installed_release") || return 65 else # Older clients can contain the four-part business version in Info.plist. case "$cn_installed_native" in *.*.*.*) cn_version_order=$(_compare_versions "$cn_release" "$cn_installed_native") || return 65 ;; *) cn_version_order=$(_compare_versions "$cn_native" "$cn_installed_native") || return 65 ;; esac fi [ "$cn_version_order" -ge 0 ] || { _log "Refusing to downgrade the installed desktop client."; return 65; } } _gui_pids() { cn_ps_output=$(_tool /bin/ps -axww -o uid=,pid=,comm=) || return 65 printf '%s\n' "$cn_ps_output" | /usr/bin/awk -v uid="$cn_uid" -v app="$1/Contents/MacOS/CloudNet" ' $1==uid { pid=$2; sub(/^[ \t]*[0-9]+[ \t]+[0-9]+[ \t]+/, ""); if ($0==app) print pid } ' } _normal_app_path() { case "$1" in /*.app) ;; *) return 65 ;; esac case "$1" in /Volumes/* | /tmp/* | /private/tmp/* | /var/tmp/* | /private/var/tmp/* | /var/folders/* | /private/var/folders/* | */AppTranslocation/*) return 65 ;; esac [ ! -L "$1" ] || return 65 } _resolve_app() { cn_ps_output=$(_tool /bin/ps -axww -o uid=,pid=,comm=) || return 65 cn_apps=$(printf '%s\n' "$cn_ps_output" | /usr/bin/awk -v uid="$cn_uid" ' $1==uid { sub(/^[ \t]*[0-9]+[ \t]+[0-9]+[ \t]+/, ""); if (sub(/\/Contents\/MacOS\/CloudNet$/, "")) print } ' | LC_ALL=C /usr/bin/sort -u) || return 65 if [ -n "$cn_apps" ]; then [ "$(printf '%s\n' "$cn_apps" | /usr/bin/wc -l | tr -d ' ')" = 1 ] || { _log "Multiple CloudNet bundle paths are running; close them before installing." return 65 } if ! _normal_app_path "$cn_apps" || ! _app_info "$cn_apps"; then _log "The running GUI is not a normal installed CloudNet bundle." return 65 fi cn_app=$cn_apps else cn_app=/Applications/CloudNet.app # A stopped GUI at a custom path can be reopened using our own receipt. # A stale receipt alone never establishes that an app is installed. if [ -f "$cn_receipt" ] && [ ! -L "$cn_receipt" ]; then cn_saved_app=$(_plist_value "$cn_receipt" AppPath) || cn_saved_app='' if _normal_app_path "$cn_saved_app" && _app_info "$cn_saved_app"; then cn_app=$cn_saved_app if ! _receipt_identity; then cn_app=/Applications/CloudNet.app; fi fi fi fi } _stop_gui() { cn_gui_list=$(_gui_pids "$cn_app") || return 65 [ -n "$cn_gui_list" ] || return 0 cn_gui_end=$(($(_now) + 10)) _bounded user 4 /usr/bin/osascript -e 'on run argv' \ -e 'with timeout of 3 seconds' -e 'tell application (item 1 of argv) to quit' \ -e 'end timeout' -e 'end run' "$cn_app" >/dev/null 2>&1 || true cn_gui_signal=TERM while [ "$(_now)" -lt "$cn_gui_end" ]; do cn_gui_list=$(_gui_pids "$cn_app") || return 65 [ -n "$cn_gui_list" ] || return 0 # Re-read UID and full executable path immediately before every signal. for cn_gui_pid in $cn_gui_list; do if _gui_pids "$cn_app" | /usr/bin/grep -qx "$cn_gui_pid"; then _user /bin/kill "-$cn_gui_signal" "$cn_gui_pid" 2>/dev/null || true fi done cn_gui_signal=KILL _tool /bin/sleep 0.2 done cn_gui_list=$(_gui_pids "$cn_app") || return 65 [ -z "$cn_gui_list" ] || { _log "The GUI did not exit within 10 seconds."; return 124; } } _start_gui() { cn_gui_list=$(_gui_pids "$cn_app") || return 65 [ -z "$cn_gui_list" ] || return 0 cn_gui_start_end=$(($(_now) + 10)) _bounded user 5 /usr/bin/open -a "$cn_app" || return 71 while [ "$(_now)" -lt "$cn_gui_start_end" ]; do cn_gui_list=$(_gui_pids "$cn_app") || return 65 [ -z "$cn_gui_list" ] || return 0 _tool /bin/sleep 0.2 done _log "The GUI did not start within 10 seconds." return 124 } _service_diagnostics() { # Only fixed protocol tokens are printed, never arbitrary core stderr or JSON. printf '%s\n' "$1" | /usr/bin/awk '/^CNET_(STAGE|ERROR|BACKEND|EXIT|HEALTH|PROBE): [a-zA-Z0-9_.:-]+$/ { print }' >&2 } _health() { cn_health_end=$(($(_now) + 35)) cn_health_attempt=0 cn_health_rc=69 cn_backend='' cn_health_output='' while [ "$cn_health_attempt" -lt 3 ]; do cn_health_remaining=$((cn_health_end - $(_now))) if [ "$cn_health_remaining" -le 0 ]; then cn_health_rc=124; return 124; fi cn_health_attempt=$((cn_health_attempt + 1)) if cn_health_output=$(_bounded user "$cn_health_remaining" /bin/bash "$cn_app/Contents/Resources/cnet" health 2>&1); then cn_backend=$(printf '%s\n' "$cn_health_output" | /usr/bin/awk '/^CNET_BACKEND: / { print $2; exit }') case "$cn_backend" in Running | NeedsLogin | NeedsMachineAuth | Stopped | Starting | NoState) return 0 ;; *) return 65 ;; esac else cn_health_rc=$? fi # A new helper explicitly distinguishes required repairs from failed queries. if printf '%s\n' "$cn_health_output" | /usr/bin/grep -qx 'CNET_HEALTH: repair-required'; then return "$cn_health_rc"; fi case "$cn_health_rc" in 1 | 69 | 124) ;; *) return "$cn_health_rc" ;; esac [ "$cn_health_attempt" -lt 3 ] && [ "$((cn_health_end - $(_now)))" -gt 1 ] || break _tool /bin/sleep 1 done return "$cn_health_rc" } _probe_bundle_version() { cn_probe_label=$1 shift for cn_probe_attempt in 1 2; do cn_probe_started=$(_now) cn_probe_remaining=$((cn_version_end - cn_probe_started)) cn_probe_timeout=15 [ "$cn_probe_remaining" -gt 0 ] || cn_probe_remaining=0 [ "$cn_probe_timeout" -le "$cn_probe_remaining" ] || cn_probe_timeout=$cn_probe_remaining cn_probe_rc=124 if [ "$cn_probe_timeout" -gt 0 ] && cn_probe_output=$(_bounded user "$cn_probe_timeout" "$@" 2>/dev/null); then return 0 else cn_probe_rc=$? [ "$cn_probe_timeout" -gt 0 ] || cn_probe_rc=124 fi printf 'CNET_PROBE: %s-attempt-%s-exit-%s-elapsed-%ss\n' "$cn_probe_label" "$cn_probe_attempt" "$cn_probe_rc" "$(($(_now) - cn_probe_started))" >&2 [ "$cn_probe_rc" -eq 124 ] && [ "$cn_probe_attempt" -eq 1 ] && [ "$((cn_version_end - $(_now)))" -gt 1 ] || break _tool /bin/sleep 1 done printf 'CNET_ERROR: %s-failed-%s\n' "$cn_probe_label" "$cn_probe_rc" >&2 return "$cn_probe_rc" } _prepare_service_versions() { # Warm and validate the final app's binaries before calling older helpers # whose fixed three-second probes cannot wait for a first security scan. cn_version_end=$(($(_now) + 35)) _probe_bundle_version bundled-daemon-version "$cn_app/Contents/Resources/cloudnetd" -version || return $? cn_daemon_commit=$(printf '%s\n' "$cn_probe_output" | /usr/bin/awk '/commit:/ {print $NF; exit}') _probe_bundle_version bundled-cli-version "$cn_app/Contents/Resources/cloudnet" version --json || return $? cn_cli_commit=$(printf '%s' "$cn_probe_output" | _tool /usr/bin/plutil -extract gitCommit raw -o - - 2>/dev/null) || return 65 cn_core_version=$(printf '%s' "$cn_probe_output" | _tool /usr/bin/plutil -extract long raw -o - - 2>/dev/null) || return 65 printf '%s\n' "$cn_daemon_commit" | /usr/bin/awk 'length($0)>=7 && length($0)<=40 && /^[a-fA-F0-9]+$/ {valid=1} END {exit !valid}' && [ "$cn_daemon_commit" = "$cn_cli_commit" ] && [ -n "$cn_core_version" ] || { _log "The bundled daemon and CLI versions are invalid or do not match." return 65 } } _service_repair_required() { cn_health_kind=$(printf '%s\n' "$cn_health_output" | /usr/bin/awk '/^CNET_HEALTH: / {print $2; exit}') case "$cn_health_kind" in repair-required) return 0 ;; query-failed) return "${cn_health_rc:-69}" ;; esac # Compatibility with released .23 helpers: verify registration and explicit # version mismatches instead of treating every failed LocalAPI query as repair. cn_service_query_rc=0 cn_service_snapshot=$(_bounded user 3 /bin/launchctl print system/world.cloudnet.client.cloudnetd 2>/dev/null) || cn_service_query_rc=$? case "$cn_service_query_rc" in 113) return 0 ;; 0) ;; *) return "$cn_service_query_rc" ;; esac printf '%s\n' "$cn_service_snapshot" | /usr/bin/grep -q '^[[:space:]]*state = running' || return 0 _user /bin/test -x /usr/local/cloudnet/bin/cloudnetd || return 0 if printf '%s\n' "$cn_health_output" | /usr/bin/grep -qx 'CNET_ERROR: running-version-mismatch'; then return 0; fi if printf '%s\n' "$cn_health_output" | /usr/bin/awk '/^CNET_VERSION: installed=[a-fA-F0-9]+ bundled=[a-fA-F0-9]+$/ { sub(/^installed=/, "", $2); sub(/^bundled=/, "", $3); if ($2 != $3) mismatch=1 } END {exit !mismatch}'; then return 0; fi [ "${cn_health_rc:-69}" -ne 124 ] || return 124 return 69 } _previous_backend() { cn_before= cn_before_json=$(_user /usr/bin/curl --silent --fail --max-time 3 --unix-socket /var/run/cloudnetd.socket \ 'http://local-cloudnetd.sock/localapi/v0/status?peers=false' 2>/dev/null) || return 0 cn_before=$(printf '%s' "$cn_before_json" | _tool /usr/bin/plutil -extract BackendState raw -o - - 2>/dev/null) || cn_before= } _can_skip() { _verify_app "$cn_app" && _receipt_identity || return 65 [ "$(_plist_value "$cn_receipt" ReleaseVersion)" = "$cn_release" ] || return 65 [ "$(_plist_value "$cn_receipt" SHA256)" = "$cn_sha" ] || return 65 _health } _mount_dmg() { cn_mount=$(_user /usr/bin/mktemp -d "$cn_work/mount.XXXXXX") || return 73 # Also attempt to detach on a mount timeout, which may occur after attach. cn_mounted=1 # diskutil image is not available on every supported macOS release. if _tool /usr/sbin/diskutil image attach --help 2>&1 | /usr/bin/grep -q -- --mountPoint; then _bounded user 60 /usr/sbin/diskutil image attach --readOnly --nobrowse --mountPoint "$cn_mount" "$cn_dmg" >/dev/null || return 65 else _bounded user 60 /usr/bin/hdiutil attach -readonly -nobrowse -mountpoint "$cn_mount" "$cn_dmg" >/dev/null || return 65 fi cn_source="$cn_mount/CloudNet.app" _verify_app "$cn_source" || { _log "The DMG app failed version, resource, or signature verification."; return 65; } } _stage_app() { cn_parent=$(dirname "$cn_app") [ -d "$cn_parent" ] && [ ! -L "$cn_parent" ] || return 73 cn_stage=$(_root /usr/bin/mktemp -d "$cn_parent/.cloudnet-install.XXXXXX") || return 73 _root /bin/chmod 0711 "$cn_stage" || return 73 _root /usr/bin/ditto "$cn_source" "$cn_stage/CloudNet.app" || return 73 _root /usr/sbin/chown -R 0:wheel "$cn_stage/CloudNet.app" || return 73 _verify_app "$cn_stage/CloudNet.app" || { _log "The staged copy failed verification; the installed app is unchanged."; return 65; } } _replace_app() { if [ -e "$cn_app" ]; then cn_previous="$cn_stage/previous.app" _root /bin/mv "$cn_app" "$cn_previous" || { _log "Cannot move the existing app into the recovery directory."; return 73; } fi if ! _root /bin/mv "$cn_stage/CloudNet.app" "$cn_app"; then _log "App replacement failed; attempting to restore the original app." if [ -n "$cn_previous" ] && ! _root /bin/mv "$cn_previous" "$cn_app"; then _log "App replacement and recovery failed. The old app is retained at $cn_previous." fi return 73 fi } _ensure_service() { _log "Checking app and service readiness." _prepare_service_versions || return $? if ! _health; then _service_diagnostics "$cn_health_output" if _service_repair_required; then :; else cn_repair_rc=$? _log "The current service state cannot be confirmed; no service installation was attempted. Retry the status check later." return "$cn_repair_rc" fi cn_operation=$(_bounded user 5 /bin/bash "$cn_app/Contents/Resources/cnet" operation-state 2>/dev/null) || return $? printf '%s\n' "$cn_operation" | /usr/bin/grep -qx 'CNET_OPERATION: idle' || { _log "Another service operation is running; retry after it finishes." return 75 } cn_service_deadline=$(($(_now) + 120)) cn_service_rc=0 cn_service_output=$(_root /bin/bash "$cn_app/Contents/Resources/cnet" install --deadline "$cn_service_deadline" 2>&1) || cn_service_rc=$? # Do not propagate arbitrary logs, credentials, or network topology. _service_diagnostics "$cn_service_output" [ "$cn_service_rc" -eq 0 ] || { _log "Service installation failed (exit $cn_service_rc). The new app is retained for repair; no success record was written." return "$cn_service_rc" } fi if _health; then :; else cn_health_check_rc=$? _service_diagnostics "$cn_health_output" _log "The installed service failed its current-user health check (exit $cn_health_check_rc)." return "$cn_health_check_rc" fi case "$cn_before:$cn_backend" in Running:NeedsLogin | Stopped:NeedsLogin | NeedsMachineAuth:NeedsLogin) _log "The previously authenticated device now needs login. This is an upgrade anomaly; identity was not reset and no login was attempted." return 65 ;; esac } _write_receipt() { cn_receipt_tmp=$(_user /usr/bin/mktemp "$cn_receipt_dir/receipt.XXXXXX") || return 73 cn_receipt_hash=$(_cdhash "$cn_app") || return 65 [ -n "$cn_receipt_hash" ] || return 65 # shellcheck disable=SC2016 # Positional arguments of the child shell. _user /bin/sh -c 'printf "%s\n" "" > "$1"' sh "$cn_receipt_tmp" || return 73 _user /usr/bin/plutil -insert AppPath -string "$cn_app" "$cn_receipt_tmp" && _user /usr/bin/plutil -insert ReleaseVersion -string "$cn_release" "$cn_receipt_tmp" && _user /usr/bin/plutil -insert NativeVersion -string "$cn_native" "$cn_receipt_tmp" && _user /usr/bin/plutil -insert SHA256 -string "$cn_sha" "$cn_receipt_tmp" && _user /usr/bin/plutil -insert CDHash -string "$cn_receipt_hash" "$cn_receipt_tmp" && _user /bin/mv "$cn_receipt_tmp" "$cn_receipt" } _report_backend() { case "$cn_backend" in Running) _log "App and service are ready; the network is running." ;; NeedsLogin) _log "App and service are installed. Log in to the network from the tray menu." ;; NeedsMachineAuth) _log "The service is ready; the device is waiting for administrator approval." ;; Stopped) _log "The service is ready; the network is stopped (existing preferences were preserved)." ;; Starting | NoState) _log "The service is ready; the network connection is recovering." ;; esac } _cleanup_macos() { cn_cleanup_rc=$? trap - EXIT HUP INT TERM _progress_stop if [ -n "$cn_previous" ] && [ -d "$cn_previous" ] && [ ! -e "$cn_app" ]; then _root /bin/mv "$cn_previous" "$cn_app" || cn_cleanup_rc=73 fi if [ -n "$cn_stage" ]; then if [ -e "$cn_app" ] || [ ! -d "$cn_previous" ]; then _root /bin/rm -rf "$cn_stage" || cn_cleanup_rc=73 else _log "The original app is retained for manual recovery at $cn_previous." fi fi if [ "$cn_gui_reopen" -eq 1 ] && [ "$cn_cleanup_rc" -ne 0 ] && [ -d "$cn_app" ]; then _start_gui || true fi if [ "$cn_mounted" -eq 1 ]; then if _bounded user 15 /usr/bin/hdiutil detach "$cn_mount" >/dev/null 2>&1; then cn_mounted=0 else _log "Cannot detach the temporary disk image at $cn_mount; eject it manually." cn_cleanup_rc=73 fi fi [ -z "$cn_receipt_tmp" ] || _user /bin/rm -f "$cn_receipt_tmp" || true if [ "$cn_mounted" -eq 0 ]; then [ -z "$cn_work" ] || _user /bin/rm -rf "$cn_work" || true fi if [ "$cn_lock_owned" -eq 1 ]; then exec 9<&- fi exit "$cn_cleanup_rc" } _install_macos() ( umask 077 _desktop_context || exit $? cn_os_version=$(_tool /usr/bin/sw_vers -productVersion) || exit 65 cn_cache="$cn_home/Library/Caches/world.cloudnet.client/installer" cn_work='' cn_stage='' cn_previous='' cn_receipt_tmp='' cn_mount='' cn_mounted=0 cn_lock_owned=0 cn_gui_reopen=0 cn_before='' cn_progress_pid='' trap _cleanup_macos EXIT trap 'exit 130' HUP INT TERM _private_dir "$cn_cache" && _private_dir "$cn_receipt_dir" || exit 73 _lock || exit $? cn_work=$(_user /usr/bin/mktemp -d "$cn_cache/.work.XXXXXX") || exit 73 _resolve_app || exit $? _metadata 1 && _check_downgrade || exit $? if _can_skip; then _log "Release $cn_release is already installed and the matching service is healthy." _start_gui || exit $? _report_backend exit 0 fi _log "Preparing CloudNet $cn_release." _download || { cn_rc=$?; _log "Download failed; the installed app and service are unchanged."; exit "$cn_rc"; } _mount_dmg || exit $? _authorize || exit $? _stage_app || { _log "Cannot stage the complete app; the installed app and service are unchanged."; exit 73; } _previous_backend cn_gui_reopen=1 _stop_gui || exit $? _replace_app || exit $? _ensure_service || exit $? _start_gui || exit $? _write_receipt || { _log "Installation completed, but its success record could not be saved."; exit 73; } _report_backend ) _restart_macos() ( cn_service_script=/usr/local/cloudnet/bin/cnet if ! _tool /bin/test -f "$cn_service_script" || ! _tool /bin/test -r "$cn_service_script" || _tool /bin/test -L "$cn_service_script"; then _log "Cannot find a readable installed cnet at $cn_service_script. Run install first." exit 66 fi # The installed helper owns authorization, locking, deadlines and readiness # checks. Do not resolve cnet from PATH or require a desktop GUI for restart. _log "Restarting the installed CloudNet network service." if _tool /bin/bash "$cn_service_script" restart; then _log "The CloudNet network service restart completed." else cn_service_rc=$? _log "Service restart failed (exit $cn_service_rc)." exit "$cn_service_rc" fi ) _reopen_macos() ( _desktop_context || exit $? if ! _resolve_app || ! _app_info "$cn_app"; then _log "Cannot find an installed CloudNet desktop app." exit 66 fi _stop_gui && _start_gui || exit $? _log "The CloudNet GUI has been reopened; the network service was not changed." ) _install_packages() { # Step 1: detect the current linux distro, version, and packaging system. # # We rely on a combination of 'uname' and /etc/os-release to find # an OS name and version, and from there work out what # installation method we should be using. # # The end result of this step is that the following three # variables are populated, if detection was successful. OS="" VERSION="" PACKAGETYPE="" APT_KEY_TYPE="" # Only for apt-based distros APT_SYSTEMCTL_START=false # Only needs to be true for Kali TRACK="${TRACK:-stable}" case "$TRACK" in stable | unstable) ;; *) echo "unsupported track $TRACK" exit 1 ;; esac if [ -f /etc/os-release ]; then # /etc/os-release populates a number of shell variables. We care about the following: # - ID: the short name of the OS (e.g. "debian", "freebsd") # - VERSION_ID: the numeric release version for the OS, if any (e.g. "18.04") # - VERSION_CODENAME: the codename of the OS release, if any (e.g. "buster") # - UBUNTU_CODENAME: if it exists, use instead of VERSION_CODENAME # shellcheck source=/dev/null . /etc/os-release case "$ID" in ubuntu | pop | neon | zorin) OS="ubuntu" if [ "${UBUNTU_CODENAME:-}" != "" ]; then VERSION="$UBUNTU_CODENAME" else VERSION="$VERSION_CODENAME" fi PACKAGETYPE="apt" # Third-party keyrings became the preferred method of # installation in Ubuntu 20.04. if expr "$VERSION_ID" : "2.*" >/dev/null; then APT_KEY_TYPE="keyring" else APT_KEY_TYPE="legacy" fi ;; debian) OS="$ID" VERSION="$VERSION_CODENAME" PACKAGETYPE="apt" # Third-party keyrings became the preferred method of # installation in Debian 11 (Bullseye). if [ -z "${VERSION_ID:-}" ]; then # rolling release. If you haven't kept current, that's on you. APT_KEY_TYPE="keyring" elif [ "$VERSION_ID" -lt 11 ]; then APT_KEY_TYPE="legacy" else APT_KEY_TYPE="keyring" fi ;; linuxmint) if [ "${UBUNTU_CODENAME:-}" != "" ]; then OS="ubuntu" VERSION="$UBUNTU_CODENAME" elif [ "${DEBIAN_CODENAME:-}" != "" ]; then OS="debian" VERSION="$DEBIAN_CODENAME" else OS="ubuntu" VERSION="$VERSION_CODENAME" fi PACKAGETYPE="apt" if [ "$VERSION_ID" -lt 5 ]; then APT_KEY_TYPE="legacy" else APT_KEY_TYPE="keyring" fi ;; elementary) OS="ubuntu" VERSION="$UBUNTU_CODENAME" PACKAGETYPE="apt" if [ "$VERSION_ID" -lt 6 ]; then APT_KEY_TYPE="legacy" else APT_KEY_TYPE="keyring" fi ;; parrot) OS="debian" PACKAGETYPE="apt" if [ "$VERSION_ID" -lt 5 ]; then VERSION="buster" APT_KEY_TYPE="legacy" else VERSION="bullseye" APT_KEY_TYPE="keyring" fi ;; raspbian) OS="$ID" VERSION="$VERSION_CODENAME" PACKAGETYPE="apt" # Third-party keyrings became the preferred method of # installation in Raspbian 11 (Bullseye). if [ "$VERSION_ID" -lt 11 ]; then APT_KEY_TYPE="legacy" else APT_KEY_TYPE="keyring" fi ;; kali) OS="debian" PACKAGETYPE="apt" YEAR="$(echo "$VERSION_ID" | cut -f1 -d.)" APT_SYSTEMCTL_START=true # Third-party keyrings became the preferred method of # installation in Debian 11 (Bullseye), which Kali switched # to in roughly 2021.x releases if [ "$YEAR" -lt 2021 ]; then # Kali VERSION_ID is "kali-rolling", which isn't distinguishing VERSION="buster" APT_KEY_TYPE="legacy" else VERSION="bullseye" APT_KEY_TYPE="keyring" fi ;; centos) OS="$ID" VERSION="$VERSION_ID" PACKAGETYPE="dnf" if [ "$VERSION" = "7" ]; then PACKAGETYPE="yum" fi ;; ol) OS="oracle" VERSION="$(echo "$VERSION_ID" | cut -f1 -d.)" PACKAGETYPE="dnf" if [ "$VERSION" = "7" ]; then PACKAGETYPE="yum" fi ;; rhel) OS="$ID" VERSION="$(echo "$VERSION_ID" | cut -f1 -d.)" PACKAGETYPE="dnf" if [ "$VERSION" = "7" ]; then PACKAGETYPE="yum" fi ;; fedora) OS="$ID" VERSION="" PACKAGETYPE="dnf" ;; openEuler | openeuler) OS="openeuler" VERSION="${VERSION_ID:-}" PACKAGETYPE="dnf" ;; rocky | almalinux | nobara) OS="fedora" VERSION="" PACKAGETYPE="dnf" ;; amzn) OS="amazon-linux" VERSION="$VERSION_ID" PACKAGETYPE="yum" ;; xenenterprise) OS="centos" VERSION="$(echo "$VERSION_ID" | cut -f1 -d.)" PACKAGETYPE="yum" ;; opensuse-leap | sles) OS="opensuse" VERSION="leap/$VERSION_ID" PACKAGETYPE="zypper" ;; opensuse-tumbleweed) OS="opensuse" VERSION="tumbleweed" PACKAGETYPE="zypper" ;; arch | archarm | endeavouros) OS="arch" VERSION="" # rolling release PACKAGETYPE="pacman" ;; manjaro | manjaro-arm) OS="manjaro" VERSION="" # rolling release PACKAGETYPE="pacman" ;; alpine) OS="$ID" VERSION="$VERSION_ID" PACKAGETYPE="apk" ;; nixos) echo "Please add Cloudnet to your NixOS configuration directly:" echo echo "services.cloudnet.enable = true;" exit 1 ;; void) OS="$ID" VERSION="" # rolling release PACKAGETYPE="xbps" ;; gentoo) OS="$ID" VERSION="" # rolling release PACKAGETYPE="emerge" ;; freebsd) OS="$ID" VERSION="$(echo "$VERSION_ID" | cut -f1 -d.)" PACKAGETYPE="pkg" ;; osmc) OS="debian" PACKAGETYPE="apt" VERSION="bullseye" APT_KEY_TYPE="keyring" ;; # TODO: wsl? # TODO: synology? qnap? esac fi # If we failed to detect something through os-release, consult # uname and try to infer things from that. if [ -z "$OS" ]; then if type uname >/dev/null 2>&1; then case "$(uname)" in FreeBSD) # FreeBSD before 12.2 doesn't have # /etc/os-release, so we wouldn't have found it in # the os-release probing above. OS="freebsd" VERSION="$(freebsd-version | cut -f1 -d.)" PACKAGETYPE="pkg" ;; OpenBSD) OS="openbsd" VERSION="$(uname -r)" PACKAGETYPE="" ;; Darwin) OS="macos" VERSION="$(sw_vers -productVersion | cut -f1-2 -d.)" PACKAGETYPE="curl" ;; Linux) OS="other-linux" VERSION="" PACKAGETYPE="" ;; esac fi fi # Step 2: having detected an OS we support, is it one of the # versions we support? OS_UNSUPPORTED= case "$OS" in ubuntu) if [ "$VERSION" != "xenial" ] && [ "$VERSION" != "bionic" ] && [ "$VERSION" != "eoan" ] && [ "$VERSION" != "focal" ] && [ "$VERSION" != "groovy" ] && [ "$VERSION" != "hirsute" ] && [ "$VERSION" != "impish" ] && [ "$VERSION" != "jammy" ] && [ "$VERSION" != "kinetic" ] && [ "$VERSION" != "lunar" ] && [ "$VERSION" != "noble" ]; then OS_UNSUPPORTED=1 fi ;; debian) if [ "$VERSION" != "stretch" ] && [ "$VERSION" != "buster" ] && [ "$VERSION" != "bullseye" ] && [ "$VERSION" != "bookworm" ] && [ "$VERSION" != "sid" ]; then OS_UNSUPPORTED=1 fi ;; raspbian) if [ "$VERSION" != "stretch" ] && [ "$VERSION" != "buster" ] && [ "$VERSION" != "bullseye" ]; then OS_UNSUPPORTED=1 fi ;; centos) if [ "$VERSION" != "7" ] && [ "$VERSION" != "8" ] && [ "$VERSION" != "9" ]; then OS_UNSUPPORTED=1 fi ;; oracle) if [ "$VERSION" != "7" ] && [ "$VERSION" != "8" ]; then OS_UNSUPPORTED=1 fi ;; rhel) if [ "$VERSION" != "7" ] && [ "$VERSION" != "8" ] && [ "$VERSION" != "9" ]; then OS_UNSUPPORTED=1 fi ;; amazon-linux) if [ "$VERSION" != "2" ]; then OS_UNSUPPORTED=1 fi ;; opensuse) if [ "$VERSION" != "leap/15.1" ] && [ "$VERSION" != "leap/15.2" ] && [ "$VERSION" != "leap/15.3" ] && [ "$VERSION" != "leap/15.4" ] && [ "$VERSION" != "tumbleweed" ]; then OS_UNSUPPORTED=1 fi ;; fedora) # All versions supported, no version checking required. ;; openeuler) # All versions supported, no version checking required. ;; arch) # Rolling release, no version checking needed. ;; manjaro) # Rolling release, no version checking needed. ;; alpine) # All versions supported, no version checking needed. # TODO: is that true? When was Cloudnet packaged? ;; void) # Rolling release, no version checking needed. ;; gentoo) # Rolling release, no version checking needed. ;; freebsd) if [ "$VERSION" != "12" ] && [ "$VERSION" != "13" ]; then OS_UNSUPPORTED=1 fi ;; openbsd) OS_UNSUPPORTED=1 ;; macos) # We delegate macOS installation to the app store, it will # perform version checks for us. # The curl desktop installer now checks the OS and appcast itself. ;; other-linux) OS_UNSUPPORTED=1 ;; *) OS_UNSUPPORTED=1 ;; esac if [ "$OS_UNSUPPORTED" = "1" ]; then case "$OS" in other-linux) echo "Couldn't determine what kind of Linux is running." echo "You could try the static binaries at:" echo "https://pkgs.cloudnet.world/$TRACK/#static" ;; "") echo "Couldn't determine what operating system you're running." ;; *) echo "$OS $VERSION isn't supported by this script yet." ;; esac echo echo "If you'd like us to support your system better, please email support@cloudnet.world" echo "and tell us what OS you're running." echo echo "Please include the following information we gathered from your system:" echo echo "OS=$OS" echo "VERSION=$VERSION" echo "PACKAGETYPE=$PACKAGETYPE" if type uname >/dev/null 2>&1; then echo "UNAME=$(uname -a)" else echo "UNAME=" fi echo if [ -f /etc/os-release ]; then cat /etc/os-release else echo "No /etc/os-release" fi exit 1 fi # Step 3: work out if we can run privileged commands, and if so, # how. if [ "$PACKAGETYPE" = curl ]; then [ "$TRACK" = stable ] || { _log "macOS desktop installation supports TRACK=stable only."; return 64; } case "$(uname -m)" in arm64 | x86_64) ;; *) _log "Unsupported macOS CPU architecture."; return 64 ;; esac [ "$(_compare_versions "$VERSION" 12.0)" -ge 0 ] || { _log "The desktop app requires macOS 12 or later."; return 64; } # Privileges are requested only after the download and bundle are verified. CAN_ROOT=1 SUDO= else CAN_ROOT= SUDO= if [ "$(id -u)" = 0 ]; then CAN_ROOT=1 SUDO="" elif type sudo >/dev/null; then CAN_ROOT=1 SUDO="sudo" elif type doas >/dev/null; then CAN_ROOT=1 SUDO="doas" fi if [ "$CAN_ROOT" != "1" ]; then echo "This installer needs to run commands as root." echo "We tried looking for 'sudo' and 'doas', but couldn't find them." echo "Either re-run this script as root, or set up sudo/doas." exit 1 fi fi # Step 4: run the installation. echo "Installing Cloudnet for $OS $VERSION, using method $PACKAGETYPE" case "$PACKAGETYPE" in apt) # Ideally we want to use curl, but on some installs we # only have wget. Detect and use what's available. CURL= if type curl >/dev/null; then CURL="curl -fsSL" elif type wget >/dev/null; then CURL="wget -q -O-" fi if [ -z "$CURL" ]; then echo "The installer needs either curl or wget to download files." echo "Please install either curl or wget to proceed." exit 1 fi export DEBIAN_FRONTEND=noninteractive if [ "$APT_KEY_TYPE" = "legacy" ] && ! type gpg >/dev/null; then $SUDO apt-get update $SUDO apt-get install -y gnupg fi set -x $SUDO mkdir -p --mode=0755 /usr/share/keyrings case "$APT_KEY_TYPE" in legacy) export TRACK=stable OS=debian VERSION=cloudnet $CURL "https://pkgs.cloudnet.world/$TRACK/$OS/$VERSION.asc" | $SUDO apt-key add - $CURL "https://pkgs.cloudnet.world/$TRACK/$OS/$VERSION.list" | $SUDO tee /etc/apt/sources.list.d/$VERSION.list ;; keyring) export TRACK=stable OS=debian VERSION=cloudnet $CURL "https://pkgs.cloudnet.world/$TRACK/$OS/$VERSION.noarmor.gpg" | $SUDO tee /usr/share/keyrings/$VERSION-keyring.gpg >/dev/null $CURL "https://pkgs.cloudnet.world/$TRACK/$OS/$VERSION.keyring.list" | $SUDO tee /etc/apt/sources.list.d/$VERSION.list ;; esac $SUDO apt-get update $SUDO apt-get install -y cloudnet $SUDO systemctl daemon-reload if [ "$APT_SYSTEMCTL_START" = "true" ]; then $SUDO systemctl enable --now cloudnetd $SUDO systemctl start cloudnetd fi set +x ;; yum) export TRACK=stable OS=rhel VERSION=cloudnet set -x $SUDO yum install yum-utils -y $SUDO yum-config-manager -y --add-repo "https://pkgs.cloudnet.world/$TRACK/$OS/$VERSION.repo" $SUDO yum install cloudnet -y $SUDO systemctl enable --now cloudnetd set +x ;; dnf) export TRACK=stable OS=rhel VERSION=cloudnet set -x $SUDO dnf config-manager --add-repo "https://pkgs.cloudnet.world/$TRACK/$OS/$VERSION.repo" $SUDO dnf install -y cloudnet $SUDO systemctl enable --now cloudnetd set +x ;; zypper) export TRACK=stable OS=rhel VERSION=cloudnet set -x $SUDO zypper ar -g -r "https://pkgs.cloudnet.world/$TRACK/$OS/$VERSION.repo" $SUDO zypper ref $SUDO zypper in cloudnet $SUDO systemctl enable --now cloudnetd set +x ;; pacman) set -x $SUDO pacman -S cloudnet --noconfirm $SUDO systemctl enable --now cloudnetd set +x ;; pkg) set -x $SUDO pkg install cloudnet $SUDO service cloudnetd enable $SUDO service cloudnetd start set +x ;; apk) set -x $SUDO apk add cloudnet $SUDO rc-update add cloudnet set +x ;; xbps) set -x $SUDO xbps-install cloudnet -y set +x ;; emerge) set -x $SUDO emerge --ask=n net-vpn/cloudnet set +x ;; brew) set -x brew install --cask cloudnet set +x ;; curl) _install_macos return $? ;; *) echo "unexpected: unknown package type $PACKAGETYPE" exit 1 ;; esac echo "Installation complete! Log in to start using cnet by running:" echo if [ -z "$SUDO" ]; then echo "cnet start" else echo "$SUDO cnet start" fi } _usage() { printf '%s\n' \ 'CloudNet online installer for macOS and Linux.' \ '' \ 'Install or upgrade (macOS 12+, Intel / Apple Silicon):' \ ' curl -fsSL https://pkgs.cloudnet.world/stable/installer.sh | sh' \ ' curl -fsSL https://pkgs.cloudnet.world/stable/installer.sh | sh -s -- upgrade' \ 'Restart the installed macOS network service (equivalent to cnet restart):' \ ' curl -fsSL https://pkgs.cloudnet.world/stable/installer.sh | sh -s -- restart' \ ' curl -fsSL https://pkgs.cloudnet.world/stable/installer.sh | CLOUDNET_ACTION=restart sh' \ 'Reopen only the macOS desktop GUI:' \ ' curl -fsSL https://pkgs.cloudnet.world/stable/installer.sh | sh -s -- reopen' \ ' curl -fsSL https://pkgs.cloudnet.world/stable/installer.sh | CLOUDNET_ACTION=reopen sh' \ 'Help:' \ ' curl -fsSL https://pkgs.cloudnet.world/stable/installer.sh | sh -s -- -h' \ '' \ 'Actions: install (default), upgrade, restart, reopen, help/-h/--help.' \ 'An explicit action overrides CLOUDNET_ACTION. Extra arguments are rejected.' \ 'macOS install/upgrade uses the official DMG (1.36.2.23+) and installs the service.' \ 'macOS supports TRACK=stable only.' \ 'Run install/upgrade/reopen in your desktop user terminal.' \ 'install/upgrade/restart may request an administrator password through sudo.' \ 'restart uses /usr/local/cloudnet/bin/cnet; it does not download or reopen the GUI.' \ 'restart briefly interrupts CloudNet networking; use a physical-network connection.' \ 'reopen only reopens the GUI; it does not change the service or request sudo.' \ 'Linux install/upgrade keeps the existing package-manager behavior.' \ 'No device reboot, network reset, or automatic network login is performed.' } _main() { [ "$#" -le 1 ] || { _usage; return 64; } cn_action=${CLOUDNET_ACTION:-install} if [ "$#" -eq 1 ]; then cn_action=$1; fi cn_action=$(printf '%s' "$cn_action" | tr '[:upper:]' '[:lower:]' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') case "$cn_action" in install | upgrade) _install_packages ;; help | -h | --help) _usage ;; restart) [ "$(uname -s)" = Darwin ] || { _log "Service restart is supported only on macOS."; return 64; } _restart_macos ;; reopen) [ "$(uname -s)" = Darwin ] || { _log "GUI reopen is supported only on macOS."; return 64; } _reopen_macos ;; *) _log "Unknown action: $cn_action"; _usage; return 64 ;; esac } _main "$@"