#!/bin/sh
#
# SecurQbit daemon installer for Linux, macOS, FreeBSD and OpenWrt routers.
#
#   curl -fsSL https://cdn.securqbit.com/securqbitd/install.sh | sudo sh
#
# It works out which package this machine needs, fetches it, verifies it,
# puts the binaries where the platform expects them, and then hands off to
# `securqbitd install`, which is the thing that actually knows how to register
# a service here — systemd, OpenRC, runit, SysV, procd, rc.d or launchd. This
# script deliberately does not reimplement any of that: one place decides how
# the daemon is set up, and it is the daemon. That includes the router
# firewall on OpenWrt; this script only passes the choice along.
#
# Windows is not covered here; its package carries install.ps1 instead.
#
# POSIX sh on purpose — this has to run under dash on Debian, busybox ash on
# Alpine and OpenWrt, and /bin/sh on FreeBSD, none of which are bash. OpenWrt
# is the strictest of those: its busybox is built down to what the image needs,
# and its wget is uclient-fetch, which is not busybox wget and not GNU wget.

set -eu

BASE_URL=${SECURQBIT_BASE_URL:-https://cdn.securqbit.com/securqbitd}
# Empty until detect_platform resolves it: the right answer is /usr/local
# everywhere except OpenWrt, which has no /usr/local at all and would not find
# a binary there on its default PATH.
PREFIX=${SECURQBIT_PREFIX:-}
VERSION=${SECURQBIT_VERSION:-}
ACTION=install
START=yes
FROM=
ROUTER=

self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd || echo "")

# ---------------------------------------------------------------- output ----

if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
	B=$(printf '\033[1m'); DIM=$(printf '\033[2m'); R=$(printf '\033[0m')
	RED=$(printf '\033[31m'); YEL=$(printf '\033[33m')
else
	B=''; DIM=''; R=''; RED=''; YEL=''
fi

info() { printf '%s==>%s %s\n' "$B" "$R" "$*"; }
step() { printf '    %s%s%s\n' "$DIM" "$*" "$R"; }
warn() { printf '%swarning:%s %s\n' "$YEL" "$R" "$*" >&2; }
die()  { printf '%serror:%s %s\n' "$RED" "$R" "$*" >&2; exit 1; }
have() { command -v "$1" >/dev/null 2>&1; }

usage() {
	cat <<'USAGE'
usage: install.sh [options]

  --version <v>    install this release instead of the current one
  --from <path>    install from a local archive or a directory of archives
                   instead of downloading
  --base-url <url> where packages are fetched from
  --prefix <dir>   install under this prefix
                   (default: /usr/local, or /usr on an OpenWrt router)
  --no-start       register the service but do not start it
  --no-router      on an OpenWrt router, install the daemon without putting
                   the LAN behind the tunnel
  --uninstall      remove the service and the installed binaries
  -h, --help       this

Environment:
  SECURQBIT_BASE_URL   same as --base-url
  SECURQBIT_VERSION    same as --version
  SECURQBIT_PREFIX     same as --prefix
USAGE
}

while [ $# -gt 0 ]; do
	case $1 in
	--version)   VERSION=${2:?--version needs a value}; shift 2 ;;
	--from)      FROM=${2:?--from needs a value}; shift 2 ;;
	--base-url)  BASE_URL=${2:?--base-url needs a value}; shift 2 ;;
	--prefix)    PREFIX=${2:?--prefix needs a value}; shift 2 ;;
	--no-start)  START=no; shift ;;
	--router)    ROUTER=yes; shift ;;
	--no-router) ROUTER=no; shift ;;
	--uninstall) ACTION=uninstall; shift ;;
	-h|--help)   usage; exit 0 ;;
	*) printf 'install.sh: unknown option %s\n\n' "$1" >&2; usage >&2; exit 2 ;;
	esac
done

# -------------------------------------------------------------- platform ----

detect_platform() {
	case $(uname -s) in
	Linux)   OS=linux ;;
	Darwin)  OS=darwin ;;
	FreeBSD) OS=freebsd ;;
	MINGW*|MSYS*|CYGWIN*)
		die "Windows is installed by running install.ps1 from the extracted
    Windows package, in an elevated PowerShell prompt." ;;
	*) die "SecurQbit has no build for $(uname -s)." ;;
	esac

	# OpenWrt is still linux/$arch as far as the package goes; what changes is
	# where things live and how little room there is for them.
	OPENWRT=no
	[ -f /etc/openwrt_release ] && OPENWRT=yes

	case $(uname -m) in
	x86_64|amd64)          ARCH=amd64 ;;
	aarch64|arm64|armv8*)  ARCH=arm64 ;;
	# One 32-bit ARM build, compiled for ARMv7 with hardware floating point.
	# That covers the routers with enough storage to hold this at all; the
	# ARMv5 and MIPS devices below it have 8-16 MB of flash and the daemon
	# does not fit, with or without an archive.
	armv7*|armv7l|armhf)   ARCH=armv7 ;;
	mips|mipsel|mips64*)
		die "SecurQbit has no build for $(uname -m).
    MIPS routers ship with 8-16 MB of flash and the daemon needs about 40 MB,
    so there is nowhere to put it. An ARM or x86_64 router works." ;;
	*) die "SecurQbit has no build for $(uname -m) — only x86_64, arm64 and armv7." ;;
	esac

	# The prefix, if the caller did not choose one. OpenWrt has no /usr/local
	# and nothing on its PATH would find a binary there, so the router gets
	# /usr — which is where opkg and apk put everything else.
	if [ -z "$PREFIX" ]; then
		if [ "$OPENWRT" = yes ]; then PREFIX=/usr; else PREFIX=/usr/local; fi
	fi

	# securqbitd goes in sbin on FreeBSD and OpenWrt, where a system daemon
	# belongs and where the init script the daemon writes looks for it. sqctl
	# is a user command everywhere.
	case $OS in
	freebsd) DAEMON_DIR=$PREFIX/sbin ;;
	*)
		if [ "$OPENWRT" = yes ]; then
			DAEMON_DIR=$PREFIX/sbin
		else
			DAEMON_DIR=$PREFIX/bin
		fi
		;;
	esac
	CTL_DIR=$PREFIX/bin
}

# check_space refuses an install that would fill a router's flash.
#
# On OpenWrt the writable filesystem is a squashfs overlay that is usually a
# few megabytes, and running it out does not fail cleanly: opkg's database,
# uci's staging area and the daemon's own config all need room to write, and a
# router with a full overlay cannot be repaired over the network. So this
# checks before unpacking rather than discovering it halfway through.
check_space() {
	[ "$OPENWRT" = yes ] || return 0
	have df || return 0

	# The overlay is what /usr is really on; df on the target directory follows
	# it without this script needing to know the mount layout.
	free_kb=$(df -k "$PREFIX" 2>/dev/null | awk 'NR==2 {print $4}')
	case $free_kb in
	''|*[!0-9]*) return 0 ;;
	esac

	# The two binaries unstripped-on-disk, plus room for the archive while it
	# is being unpacked and for the daemon's own state afterwards.
	needed_kb=90000
	if [ "$free_kb" -lt "$needed_kb" ]; then
		die "not enough room on $PREFIX: $((free_kb / 1024)) MB free, about $((needed_kb / 1024)) MB needed.

    This router's flash is too small for SecurQbit as it stands. The usual fix
    is an extroot on a USB stick or an SD card, which moves the overlay onto
    real storage:
      https://openwrt.org/docs/guide-user/additional-software/extroot_configuration"
	fi
	step "$((free_kb / 1024)) MB free on $PREFIX"
	return 0
}

# check_tun reports a missing tun module before the install rather than after.
#
# OpenWrt ships tun as a separate package and leaves it out of the default
# image, so on a stock router the first connect is the thing that fails — with
# a message about a device, an hour after the install that looked fine.
check_tun() {
	[ "$OPENWRT" = yes ] || return 0
	[ -c /dev/net/tun ] && return 0
	modprobe tun 2>/dev/null && [ -c /dev/net/tun ] && return 0

	if have apk; then
		warn "the tun kernel module is missing. Install it with:
    apk add kmod-tun"
	else
		warn "the tun kernel module is missing. Install it with:
    opkg update && opkg install kmod-tun"
	fi
	# Explicitly successful: a missing module is a warning, and `set -e` must
	# not turn it into an aborted install.
	return 0
}

# require_root re-runs the script under sudo when it can. The options are
# rebuilt from the parsed state rather than forwarded verbatim: sudo's env_reset
# would drop SECURQBIT_* anyway, so everything that configures this run has to
# travel as a flag.
#
# Piped from curl there is no file to re-run, so say the one command that works
# instead of failing halfway through with a permission error.
require_root() {
	[ "$(id -u)" = 0 ] && return 0

	set -- --base-url "$BASE_URL" --prefix "$PREFIX"
	if [ -n "$VERSION" ]; then set -- "$@" --version "$VERSION"; fi
	if [ -n "$FROM" ]; then set -- "$@" --from "$FROM"; fi
	if [ "$START" = no ]; then set -- "$@" --no-start; fi
	if [ "$ROUTER" = yes ]; then set -- "$@" --router; fi
	if [ "$ROUTER" = no ]; then set -- "$@" --no-router; fi
	if [ "$ACTION" = uninstall ]; then set -- "$@" --uninstall; fi

	if [ -f "$0" ]; then
		if have sudo; then
			info "re-running with sudo"
			exec sudo -- "$0" "$@"
		elif have doas; then
			info "re-running with doas"
			exec doas -- "$0" "$@"
		fi
	fi
	die "this needs root. Run:
    curl -fsSL $BASE_URL/install.sh | sudo sh"
}

# ------------------------------------------------------------- fetch/hash ----

# fetch downloads one URL.
#
# The flags are spelled out one per argument and there is no `--` terminator,
# because OpenWrt's wget is uclient-fetch: it does not bundle short options and
# it treats `--` as a URL. Every other wget accepts this form too, so there is
# no reason to have two.
fetch() { # url dest
	if have curl; then
		curl -fsSL -o "$2" "$1" || return 1
	elif have wget; then
		wget -q -O "$2" "$1" || return 1
	else
		die "neither curl nor wget is installed.

    On OpenWrt:  opkg update && opkg install curl     (or: apk add curl)"
	fi
}

sha256_of() {
	if have sha256sum;  then sha256sum "$1"    | awk '{print $1}'
	elif have shasum;   then shasum -a 256 "$1"| awk '{print $1}'
	elif have sha256;   then sha256 -q "$1"
	else return 1
	fi
}

# verify checks the archive against SHA256SUMS. This catches a truncated or
# corrupted download; it is not by itself protection against a hostile mirror,
# because the sums travel the same channel as the package. TLS is what makes
# the channel trustworthy, which is why the default base URL is https and an
# http override is called out loudly.
verify() { # archive sumsfile name
	want=$(awk -v n="$3" '$2 == n || $2 == "*" n {print $1}' "$2" | head -n1)
	[ -n "$want" ] || die "$3 is not listed in SHA256SUMS."
	got=$(sha256_of "$1") || { warn "no sha256 tool here — skipping verification"; return 0; }
	[ "$want" = "$got" ] || die "checksum mismatch for $3.
    expected $want
    got      $got"
	step "checksum ok"
}

# ------------------------------------------------------------- uninstall ----

do_uninstall() {
	daemon=$DAEMON_DIR/securqbitd
	[ -x "$daemon" ] || daemon=$(command -v securqbitd || echo "")

	if [ -n "$daemon" ] && [ -x "$daemon" ]; then
		# Bring the tunnel down through the daemon rather than killing it: it
		# has the host's original routes and resolvers to put back.
		if have sqctl; then sqctl disconnect >/dev/null 2>&1 || true; fi
		info "removing the service"
		"$daemon" uninstall || warn "the service manager reported a problem; removing the binaries anyway"
	else
		warn "no securqbitd found — removing whatever binaries are present"
	fi

	rm -f "$DAEMON_DIR/securqbitd" "$PREFIX/bin/securqbitd" "$PREFIX/sbin/securqbitd" "$CTL_DIR/sqctl"
	info "SecurQbit is removed."
	if [ "$OPENWRT" = yes ]; then
		printf '    %sThe firewall zone and the LAN forwarding were removed with the service.%s\n' "$DIM" "$R"
	fi
	printf '    %sState and logs are left in place; see the layout table in the README.%s\n' "$DIM" "$R"
}

# --------------------------------------------------------------- install ----

# resolve_version reads the version the CDN currently publishes. Kept in a
# plain text file so the installer needs nothing but a GET.
resolve_version() {
	[ -n "$VERSION" ] && return 0
	step "resolving the current version"
	fetch "$BASE_URL/latest" "$tmp/latest" ||
		die "could not reach $BASE_URL. Check the network, or pass --version."
	VERSION=$(tr -d ' \t\r\n' <"$tmp/latest")
	[ -n "$VERSION" ] || die "$BASE_URL/latest is empty."
}

# stage_local unpacks a release the caller already has: either the directory
# this script was extracted into, or whatever --from points at.
stage_local() {
	src=$1
	if [ -d "$src" ] && [ -x "$src/securqbitd" ]; then
		step "using the binaries in $src"
		STAGE=$src
		return 0
	fi

	archive=""
	if [ -f "$src" ]; then
		archive=$src
	elif [ -d "$src" ]; then
		for f in "$src"/securqbit-*-"$OS"-"$ARCH".tar.gz; do
			if [ -f "$f" ]; then archive=$f; break; fi
		done
		[ -n "$archive" ] || die "no $OS-$ARCH archive in $src."
	else
		die "$src does not exist."
	fi

	step "unpacking $(basename "$archive")"
	if [ -f "$(dirname "$archive")/SHA256SUMS" ]; then
		verify "$archive" "$(dirname "$archive")/SHA256SUMS" "$(basename "$archive")"
	fi
	mkdir -p "$tmp/x"
	tar -xzf "$archive" -C "$tmp/x"
	STAGE=$(find "$tmp/x" -maxdepth 2 -name securqbitd -type f -exec dirname {} \; | head -n1)
	[ -n "$STAGE" ] || die "$archive does not contain securqbitd."
}

stage_remote() {
	resolve_version
	name=securqbit-$VERSION-$OS-$ARCH.tar.gz
	url=$BASE_URL/$VERSION/$name

	info "downloading SecurQbit $VERSION for $OS/$ARCH"
	step "$url"
	fetch "$url" "$tmp/$name" || die "could not download $url.
    That version may not have a $OS/$ARCH build."
	if fetch "$BASE_URL/$VERSION/SHA256SUMS" "$tmp/SHA256SUMS"; then
		verify "$tmp/$name" "$tmp/SHA256SUMS" "$name"
	else
		warn "no SHA256SUMS published for $VERSION — cannot verify the download"
	fi

	mkdir -p "$tmp/x"
	tar -xzf "$tmp/$name" -C "$tmp/x"
	STAGE=$(find "$tmp/x" -maxdepth 2 -name securqbitd -type f -exec dirname {} \; | head -n1)
	[ -n "$STAGE" ] || die "$name does not contain securqbitd."
}

# install_bin renames the new binary into place rather than writing through the
# old one: an executable that is currently running gives ETXTBSY on Linux and
# FreeBSD, which is exactly the upgrade case.
install_bin() { # src dest
	mkdir -p "$(dirname "$2")"
	cp -- "$1" "$2.new.$$"
	chmod 0755 "$2.new.$$"
	mv -- "$2.new.$$" "$2"
}

do_install() {
	check_space

	if [ -n "$FROM" ]; then
		stage_local "$FROM"
	elif [ -n "$self_dir" ] && [ -x "$self_dir/securqbitd" ]; then
		# Running install.sh out of an extracted release archive.
		stage_local "$self_dir"
	else
		stage_remote
	fi

	# An upgrade over a running daemon: stop it first so it restores the
	# host's routes and resolvers before its binary is replaced.
	if [ -x "$DAEMON_DIR/securqbitd" ]; then
		info "stopping the running service"
		have sqctl && sqctl disconnect >/dev/null 2>&1 || true
		"$DAEMON_DIR/securqbitd" service stop >/dev/null 2>&1 || true
	fi

	info "installing into $PREFIX"
	install_bin "$STAGE/securqbitd" "$DAEMON_DIR/securqbitd"
	install_bin "$STAGE/sqctl"      "$CTL_DIR/sqctl"
	step "$DAEMON_DIR/securqbitd"
	step "$CTL_DIR/sqctl"

	check_tun

	# Everything platform-specific — the service manager, the securqbit group,
	# the state directories, the tun module, and on a router the firewall that
	# puts the LAN behind the tunnel — is the daemon's own job. This script
	# only forwards the choice.
	info "setting up the service"
	echo
	set -- install
	if [ "$START" = yes ]; then set -- "$@" --start; else set -- "$@" --start=false; fi
	if [ "$ROUTER" = yes ]; then set -- "$@" --router; fi
	if [ "$ROUTER" = no ]; then set -- "$@" --no-router; fi
	"$DAEMON_DIR/securqbitd" "$@"

	if [ "$OPENWRT" = yes ]; then
		echo
		info "This router is now a SecurQbit gateway."
		step "sign in:   sqctl login"
		step "connect:   sqctl connect --fastest"
		step "logs:      logread -f -e securqbitd"
	fi

	if ! printf '%s' "$PATH" | tr ':' '\n' | grep -qx "$CTL_DIR"; then
		echo
		warn "$CTL_DIR is not on your PATH — run sqctl as $CTL_DIR/sqctl until it is"
	fi
}

# ------------------------------------------------------------------ main ----

case $BASE_URL in
https://*) ;;
*) warn "$BASE_URL is not https — the download cannot be authenticated" ;;
esac

# sudo keeps the working directory, but making --from absolute here means a
# relative path still resolves after a re-exec from anywhere.
if [ -n "$FROM" ]; then
	case $FROM in
	/*) ;;
	*) FROM=$PWD/$FROM ;;
	esac
fi

detect_platform
require_root

tmp=$(mktemp -d 2>/dev/null || mktemp -d -t securqbit)
trap 'rm -rf "$tmp"' EXIT INT TERM

if [ "$ACTION" = uninstall ]; then
	do_uninstall
else
	do_install
fi
