#!/bin/sh set -eu # Release tooling replaces these values in the published HTTPS installer. RELEASE_BASE='https://get.serverx.tech/releases/0.3.11' UPDATE_BASE='https://get.serverx.tech' case "$UPDATE_BASE" in *'__'*) UPDATE_BASE=$RELEASE_BASE;; esac GATEWAY='https://api.serverx.tech' PUBLIC_KEY='mP3H47k484eElZQVuPrQ+mAV0CW/aFPG1vGiuxyXUZM=' BUNDLE='' CODE_FILE='' # Preserve the dedicated-account behavior of existing sudo installation commands. # New dashboard commands explicitly select auto to use the invoking account. MODE=full_access REMOTE_MANAGEMENT=false VERIFY_ONLY=false CHECK_ONLY=false IDENTITY_ACTION=ask BIND_HOST=false DESKTOP=false DESKTOP_READ_ONLY=false PHYSICAL=true while [ "$#" -gt 0 ]; do case "$1" in --bundle) BUNDLE=${2:?Bundle directory required}; shift 2;; --enrollment-code-file) CODE_FILE=${2:?Enrollment code file required}; shift 2;; --mode) MODE=${2:?Local mode required}; shift 2;; --remote-management) REMOTE_MANAGEMENT=true; shift;; --desktop) DESKTOP=true; shift;; --desktop-read-only) DESKTOP=true; DESKTOP_READ_ONLY=true; shift;; --no-physical-input) PHYSICAL=false; shift;; --verify-only) VERIFY_ONLY=true; shift;; --check) CHECK_ONLY=true; shift;; --bind-host) BIND_HOST=true; shift;; --repair) [ "$IDENTITY_ACTION" = ask ] || { echo 'Choose only one account action.' >&2; exit 1; } IDENTITY_ACTION=repair; shift;; --reconnect|--keep-identity) [ "$IDENTITY_ACTION" = ask ] || { echo 'Choose only one of --reconnect or --keep-identity.' >&2; exit 1; } IDENTITY_ACTION=$1; shift;; *) echo 'Usage: install.sh [--bundle DIR] [--enrollment-code-file FILE] [--mode auto|read_only|scoped|custom|full_access|full_root] [--remote-management] [--desktop|--desktop-read-only] [--no-physical-input] [--reconnect | --keep-identity | --repair [--bind-host]] [--check | --verify-only]' >&2; exit 1;; esac done case "$MODE" in auto|read_only|scoped|custom|full_access|full_root) ;; *) echo 'Unsupported Linux access mode' >&2; exit 1;; esac [ "$CHECK_ONLY$VERIFY_ONLY" != truetrue ] || { echo 'Choose --check or --verify-only, not both.' >&2; exit 1; } case "$RELEASE_BASE$GATEWAY$PUBLIC_KEY" in *'__'*) echo 'Unsigned development installer: publish a configured signed release first.' >&2; exit 1;; esac for tool in curl python3; do command -v "$tool" >/dev/null || { echo "Install required tool: $tool" >&2; exit 1; } done case "$(uname -m)" in x86_64) arch=amd64;; aarch64|arm64) arch=arm64;; *) echo 'Unsupported architecture' >&2; exit 1;; esac if [ "$(id -u)" = 0 ]; then [ "$MODE" != auto ] || MODE=full_root INSTALL_KIND=system PROGRAM_DIR=/opt/serverx BIN_LINK=/usr/local/bin/serverx CONFIG_DIR=/etc/serverx CONFIG=$CONFIG_DIR/config.yaml STATE_DIR=/var/lib/serverx LOG_DIR=/var/log/serverx else [ "$MODE" != auto ] || MODE=full_access INSTALL_KIND=user [ "$MODE" != full_root ] || { echo 'full_root requires sudo/root. Re-run with sudo or choose full_access.' >&2; exit 1; } : "${HOME:?HOME is required for a user installation}" case "$HOME" in /*) ;; *) echo 'HOME must be an absolute directory.' >&2; exit 1;; esac PROGRAM_DIR="$HOME/.local/lib/serverx" BIN_DIR="$HOME/.local/bin" BIN_LINK="$BIN_DIR/serverx" CONFIG_DIR="$HOME/.config/serverx" CONFIG="$CONFIG_DIR/config.yaml" STATE_DIR="$HOME/.local/share/serverx" LOG_DIR="$HOME/.local/state/serverx" fi binding='' machine_id='' if [ "$INSTALL_KIND" = user ]; then binding="$HOME/.config/systemd/user/serverx.service.d/40-serverx-host.conf" fi if [ "$BIND_HOST" = true ] && { [ "$IDENTITY_ACTION" != repair ] || [ -z "$binding" ]; }; then echo '--bind-host requires --repair from the primary Linux user installation.' >&2 exit 1 fi if [ "$IDENTITY_ACTION" = repair ]; then if [ -n "$CODE_FILE" ] || [ "$DESKTOP$CHECK_ONLY$VERIFY_ONLY" != falsefalsefalse ]; then echo '--repair uses an existing installation. Run it without enrollment, desktop or verification options.' >&2 exit 1 fi if [ ! -f "$CONFIG" ]; then echo 'REPAIR_NOT_INSTALLED: run the regular installer to connect this host first.' >&2 exit 1 fi fi # Desktop runs with the graphical user's identity and session bus, never root. if [ "$DESKTOP" = true ] && [ "$VERIFY_ONLY" != true ] && [ "$CHECK_ONLY" != true ] && [ "$INSTALL_KIND" != user ]; then echo 'Install Linux desktop from the signed-in user terminal, without sudo. Choose --mode full_access --desktop.' >&2 exit 1 fi # Check the manager before downloading, replacing files or consuming a pairing # code. Having systemctl on PATH does not mean a user session can run services. if [ "$VERIFY_ONLY" != true ]; then service_ready=false if command -v systemctl >/dev/null 2>&1; then if [ "$INSTALL_KIND" = user ]; then if systemctl --user show-environment >/dev/null 2>&1; then service_ready=true elif [ -S "/run/user/$(id -u)/bus" ]; then export XDG_RUNTIME_DIR="/run/user/$(id -u)" export DBUS_SESSION_BUS_ADDRESS="unix:path=$XDG_RUNTIME_DIR/bus" if systemctl --user show-environment >/dev/null 2>&1; then service_ready=true; fi fi elif systemctl show-environment >/dev/null 2>&1; then service_ready=true fi fi if [ "$service_ready" != true ] && { [ "$INSTALL_KIND" = system ] || [ "$IDENTITY_ACTION" = repair ] || { [ -n "$binding" ] && [ -f "$binding" ]; }; }; then echo "SERVICE_UNAVAILABLE: Cannot reach the systemd $INSTALL_KIND service manager." >&2 echo 'ServerX needs permission to run a persistent service. Some shared hosting plans do not allow this.' >&2 echo 'Ask your hosting provider whether a systemd user service is supported for your account.' >&2 echo 'No installation files were changed and no enrollment code was used. You can reuse the code while it is valid.' >&2 exit 1 fi if [ "$service_ready" != true ]; then echo 'No user systemd service is available. ServerX will run as a background process under your account.' echo 'After a host reboot or provider process cleanup, run serverx repair. Hosting limits still apply.' fi if [ -n "$binding" ] && [ "$service_ready" = true ]; then machine_id=$(cat /etc/machine-id 2>/dev/null || :) if [ -f "$binding" ] || { [ -n "$machine_id" ] && systemd-analyze --user condition "ConditionHost=$machine_id" >/dev/null 2>&1; }; then BIND_HOST=true elif [ "$BIND_HOST" != true ]; then echo 'This systemd installation does not support automatic machine binding.' >&2 fi fi if [ -n "$binding" ] && { [ -e "$binding" ] || [ -L "$binding" ]; }; then if [ -L "$binding" ] || [ "$(cat "$binding")" != "$(printf '[Unit]\nConditionHost=%s' "$machine_id")" ]; then echo 'REPAIR_WRONG_HOST: this installation belongs to another machine. Run ServerX on the primary host.' >&2 exit 1 fi fi echo "ServerX installation: $INSTALL_KIND service, local mode $MODE, account $(id -un)." if [ -f "$CONFIG" ]; then echo "Existing local settings in $CONFIG will be preserved."; fi fi temp=$(mktemp -d) trap 'rm -rf -- "$temp"' EXIT HUP INT TERM # Probe actual Ed25519 verification, not an OpenSSL version string. A legacy # OpenSSL earlier on PATH must not prevent using an already compatible backend. # -I isolates the helper from PYTHONPATH, user modules and PYTHONOPTIMIZE. cat > "$temp/verify.py" <<'SERVERX_VERIFY_PY' """Installer-only Ed25519 compatibility; never execute an unverified download.""" import base64 import binascii import json import os from pathlib import Path import shutil import subprocess import sys SYSTEM_OPENSSL = ("/usr/bin/openssl", "/bin/openssl", "/usr/local/bin/openssl", "/usr/bin/openssl3", "/usr/local/bin/openssl3") DER_PREFIX = bytes.fromhex("302a300506032b6570032100") # RFC 8032, section 7.1, TEST 2. No private key is needed for the self-test. PROBE_KEY = bytes.fromhex("3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c") PROBE_MESSAGE = bytes.fromhex("72") PROBE_SIGNATURE = bytes.fromhex( "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da08" "5ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00") def openssl_verify(executable, directory, key, message, signature): (directory / "verify-public.der").write_bytes(DER_PREFIX + key) (directory / "verify-message").write_bytes(message) (directory / "verify-signature").write_bytes(signature) try: result = subprocess.run([ executable, "pkeyutl", "-verify", "-pubin", "-inkey", str(directory / "verify-public.der"), "-keyform", "DER", "-rawin", "-in", str(directory / "verify-message"), "-sigfile", str(directory / "verify-signature")], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10) return result.returncode == 0 except (OSError, subprocess.TimeoutExpired): return False def python_verify(key, message, signature): from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from cryptography.exceptions import InvalidSignature try: Ed25519PublicKey.from_public_bytes(key).verify(signature, message) return True except InvalidSignature: return False def self_test(verify): try: return (verify(PROBE_KEY, PROBE_MESSAGE, PROBE_SIGNATURE) and not verify(PROBE_KEY, PROBE_MESSAGE, bytes(64))) except Exception: # A missing module, disabled algorithm or broken provider is not usable. return False def select_backend(directory): primary = shutil.which("openssl") seen = set() for candidate in (primary,) + SYSTEM_OPENSSL: if not candidate or not os.path.isfile(candidate) or not os.access(candidate, os.X_OK): continue resolved = os.path.realpath(candidate) if resolved in seen: continue seen.add(resolved) if self_test(lambda key, message, signature: openssl_verify(resolved, directory, key, message, signature)): backend = {"kind": "openssl", "path": resolved} if primary and resolved != os.path.realpath(primary): print("VERIFIER_RECOVERED: OpenSSL on PATH cannot verify Ed25519; " "automatically using " + resolved + ". System packages were not changed.") print("Signature verifier ready: " + resolved + " (Ed25519 self-test passed).") return backend if self_test(python_verify): print("VERIFIER_RECOVERED: No compatible OpenSSL CLI; automatically using " "installed Python cryptography (Ed25519 self-test passed). " "System packages were not changed.") return {"kind": "python"} raise RuntimeError( "VERIFIER_UNAVAILABLE: No working Ed25519 signature verifier was found. " "ServerX checked OpenSSL on PATH, standard system locations and installed Python cryptography.\n" "Install OpenSSL 3 or your distribution's python3-cryptography package, then rerun the same installer. " "On managed hosting, ask the provider to make one available.\n" "No installation files were changed and no enrollment code was used. " "Signature verification cannot be skipped.") def decode(value, length): raw = base64.b64decode(value.strip(), validate=True) if len(raw) != length: raise ValueError("Invalid Ed25519 key or signature length") return raw def verify_manifest(directory, public_key): try: key = decode(public_key, 32) signature = decode((directory / "signature.b64").read_bytes(), 64) message = (directory / "manifest.json").read_bytes() backend = json.loads((directory / "verifier.json").read_text()) if backend["kind"] == "openssl": valid = openssl_verify(backend["path"], directory, key, message, signature) elif backend["kind"] == "python": valid = python_verify(key, message, signature) else: valid = False except (OSError, ValueError, KeyError, ImportError, binascii.Error): valid = False if not valid: raise RuntimeError( "RELEASE_SIGNATURE_INVALID: The release signature could not be verified. " "The download may be damaged or altered. Installation stopped before executing it; " "no enrollment code was used. Retry the official installer or contact ServerX support.") def main(): directory = Path(sys.argv[2]) if sys.argv[1] == "select": (directory / "verifier.json").write_text(json.dumps(select_backend(directory))) elif sys.argv[1] == "verify": verify_manifest(directory, sys.argv[3]) else: raise RuntimeError("VERIFIER_ERROR: Unknown verification operation.") if __name__ == "__main__": try: main() except Exception as error: print(str(error) if isinstance(error, RuntimeError) else "VERIFIER_ERROR: Signature verification could not complete. " "No unverified agent was installed; contact ServerX support.", file=sys.stderr) sys.exit(1) SERVERX_VERIFY_PY python3 -I "$temp/verify.py" select "$temp" if [ "$CHECK_ONLY" = true ]; then echo 'Environment check passed, including Ed25519 verification. No installation files were changed and no enrollment code was used.' exit 0 fi if [ -n "$BUNDLE" ]; then cp -- "$BUNDLE/linux-$arch.json" "$temp/manifest.json" cp -- "$BUNDLE/linux-$arch.json.sig" "$temp/signature.b64" else curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$RELEASE_BASE/linux-$arch.json" -o "$temp/manifest.json" curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$RELEASE_BASE/linux-$arch.json.sig" -o "$temp/signature.b64" fi python3 -I "$temp/verify.py" verify "$temp" "$PUBLIC_KEY" python3 -I - "$temp/manifest.json" "$RELEASE_BASE" "$arch" > "$temp/url" <<'PY' import json,math,re,sys,time,urllib.parse try: m=json.load(open(sys.argv[1]));base=urllib.parse.urlsplit(sys.argv[2]);url=urllib.parse.urlsplit(m['url']) expires=m['expires'] if (m['os']!='linux' or m['arch']!=sys.argv[3] or not isinstance(expires,(int,float)) or isinstance(expires,bool) or not math.isfinite(expires) or expires<=time.time() or not isinstance(m['sha256'],str) or not re.fullmatch(r'[0-9a-f]{64}',m['sha256'])): raise ValueError('Invalid platform, architecture, release metadata or checksum') if url.scheme!='https' or url.netloc!=base.netloc or url.username is not None or url.fragment: raise ValueError('Invalid download origin') except (OSError,ValueError,KeyError,TypeError): sys.exit('RELEASE_MANIFEST_INVALID: Invalid signed release metadata. No agent was installed and no enrollment code was used.') print(m['url']) PY if [ -n "$BUNDLE" ]; then cp -- "$BUNDLE/serverx-linux-$arch" "$temp/serverx" else curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$(cat "$temp/url")" -o "$temp/serverx" fi python3 -I - "$temp/manifest.json" "$temp/serverx" <<'PY' import json,hashlib,sys try: m=json.load(open(sys.argv[1]));h=hashlib.sha256() with open(sys.argv[2],'rb') as binary: for chunk in iter(lambda:binary.read(1024*1024),b''): h.update(chunk) if h.hexdigest()!=m['sha256']: raise ValueError('Checksum mismatch') except (OSError,ValueError,KeyError,TypeError): sys.exit('RELEASE_CHECKSUM_INVALID: The agent download is damaged or altered. Nothing was installed and no enrollment code was used. Retry the official installer.') PY if [ "$VERIFY_ONLY" = true ]; then echo "Verified ServerX Linux $arch. No installation performed."; exit 0; fi if [ "$IDENTITY_ACTION" != repair ]; then if [ "$INSTALL_KIND" = user ]; then mkdir -p "$BIN_DIR"; fi mkdir -p "$PROGRAM_DIR" "$CONFIG_DIR" "$STATE_DIR" "$LOG_DIR" # Keep the owner's full access while preventing group-writable binaries from # disabling signed user updates (including reinstalls under a permissive umask). chmod 755 "$PROGRAM_DIR" chmod 700 "$CONFIG_DIR" "$STATE_DIR" "$LOG_DIR" if [ -L "$PROGRAM_DIR/serverx" ] || ! cmp -s "$temp/serverx" "$PROGRAM_DIR/serverx"; then if [ -f "$PROGRAM_DIR/serverx" ]; then cp -p "$PROGRAM_DIR/serverx" "$PROGRAM_DIR/serverx.previous"; fi cp "$temp/serverx" "$PROGRAM_DIR/serverx.new" chmod 755 "$PROGRAM_DIR/serverx.new" mv -f "$PROGRAM_DIR/serverx.new" "$PROGRAM_DIR/serverx" fi chmod 755 "$PROGRAM_DIR/serverx" ln -sfn "$PROGRAM_DIR/serverx" "$BIN_LINK" if [ ! -f "$CONFIG" ]; then cat > "$CONFIG" <&2 return 1 fi uid=$(id -u) linger=$(timeout 5s loginctl show-user "$uid" -p Linger --value 2>/dev/null || :) if [ "$linger" != yes ]; then timeout 5s loginctl --no-ask-password enable-linger "$uid" >/dev/null 2>&1 || : linger=$(timeout 5s loginctl show-user "$uid" -p Linger --value 2>/dev/null || :) fi if [ "$linger" = yes ]; then echo 'Automatic startup enabled. The user service stays available after logout.' else echo 'The user service is active. Startup without a login requires the hosting provider to allow systemd user lingering.' >&2 fi } if [ "$IDENTITY_ACTION" = repair ]; then finish_user_service; exit 0; fi serverx_install_desktop_linux() ( #!/bin/sh set -eu [ "$(uname -s)" = Linux ] || { echo 'Linux is required' >&2; exit 1; } [ "$(id -u)" != 0 ] || { echo 'Install desktop from the signed-in user terminal, without sudo. Full Root server agents remain separate from user desktop sessions.' >&2; exit 1; } serverx_binary=${SERVERX_BINARY:?Set SERVERX_BINARY to the absolute trusted ServerX executable} serverx_config=${SERVERX_CONFIG:-$HOME/.config/serverx/config.yaml} case "$serverx_binary" in /*) ;; *) echo 'Absolute SERVERX_BINARY required' >&2; exit 1;; esac for tool in python3 curl tar loginctl; do command -v "$tool" >/dev/null || { echo "Required for Linux desktop: $tool" >&2; exit 1; }; done version=1.2.0 case "$(uname -m)" in x86_64) triple=x86_64-unknown-linux-gnu; digest=591ae7f4a38479dc045df97e27447c0c8b530441730ab6b990e27c813162af44 ;; aarch64|arm64) triple=aarch64-unknown-linux-gnu; digest=68e8ebbb4f80e44485d2f1cf972943a38ee43108e2e0ce56bdfa641fd1599f68 ;; *) echo 'Linux x86_64 or aarch64 is required' >&2; exit 1;; esac umask 077 desktop_tmp=$(mktemp -d) trap 'rm -rf -- "$desktop_tmp"' EXIT HUP INT TERM engine_dir="$HOME/.local/lib/serverx/desktop/$version" session_file="$(dirname "$serverx_config")/desktop-session.json" python3 -I - "$session_file" <<'PY' import json,os,pathlib,stat,sys,tempfile uid=os.geteuid() keys=['DISPLAY','WAYLAND_DISPLAY','XAUTHORITY','XDG_RUNTIME_DIR','DBUS_SESSION_BUS_ADDRESS','XDG_SESSION_TYPE','XDG_CURRENT_DESKTOP','XDG_SESSION_DESKTOP'] env={k:os.environ[k] for k in keys if os.environ.get(k)} if not (env.get('DISPLAY') or env.get('WAYLAND_DISPLAY')) or not env.get('DBUS_SESSION_BUS_ADDRESS'): sys.exit('Run this installer inside the signed-in graphical session. DISPLAY/Wayland and the session D-Bus are required.') runtime=pathlib.Path(env.get('XDG_RUNTIME_DIR','')) if not runtime.is_absolute(): sys.exit('A private XDG_RUNTIME_DIR is required.') info=runtime.lstat() if not stat.S_ISDIR(info.st_mode) or info.st_uid!=uid or info.st_mode&0o077: sys.exit('Invalid user runtime directory.') p=pathlib.Path(sys.argv[1]);p.parent.mkdir(parents=True,exist_ok=True) info=p.parent.lstat() if not stat.S_ISDIR(info.st_mode) or info.st_uid!=uid or info.st_mode&0o077: sys.exit('The configuration directory must be private and user-owned.') if p.is_symlink(): sys.exit('The session file cannot be a symlink.') fd,tmp=tempfile.mkstemp(prefix='.desktop-session-',dir=p.parent) try: with os.fdopen(fd,'w') as f: json.dump({'uid':str(uid),'environment':env},f);f.flush();os.fsync(f.fileno()) os.replace(tmp,p) finally: if os.path.exists(tmp): os.unlink(tmp) PY curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' "https://github.com/vercel-labs/agent-desktop/releases/download/v$version/agent-desktop-v$version-$triple.tar.gz" -o "$desktop_tmp/upstream.tar.gz" python3 -I - "$desktop_tmp/upstream.tar.gz" "$digest" "$engine_dir" <<'PY' import hashlib,os,pathlib,sys,tarfile,tempfile archive,digest,destination=sys.argv[1:] if hashlib.sha256(pathlib.Path(archive).read_bytes()).hexdigest()!=digest: sys.exit('Upstream desktop archive digest mismatch') root=pathlib.Path(destination);root.mkdir(parents=True,exist_ok=True);root.chmod(0o700) with tarfile.open(archive,'r:gz') as t: members=t.getmembers() if len(members)!=2 or {m.name for m in members}!={'agent-desktop','agent-desktop-linux-clipboard-owner'} or any(not m.isfile() for m in members): sys.exit('Unexpected desktop archive members') for m in members: fd,tmp=tempfile.mkstemp(prefix='.engine-',dir=root) try: with os.fdopen(fd,'wb') as f: f.write(t.extractfile(m).read());f.flush();os.fsync(f.fileno()) os.chmod(tmp,0o700);os.replace(tmp,root/m.name) finally: if os.path.exists(tmp): os.unlink(tmp) PY engine_hash=$(python3 -I -c 'import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest())' "$engine_dir/agent-desktop") "$serverx_binary" desktop configure --config "$serverx_config" --binary "$engine_dir/agent-desktop" --sha256 "$engine_hash" --state-dir "$HOME/.local/share/serverx/Desktop" --user "$(id -u)" --session-file "$session_file" "$@" printf '%s\n' 'Linux desktop configured for your user. Requires an unlocked graphical login, systemd-logind and a working at-spi2-core bus. X11 and Wayland capabilities differ; check serverx desktop status.' ) if [ "$DESKTOP" = true ]; then set -- if [ "$DESKTOP_READ_ONLY" = false ]; then set -- "$@" --allow-input; fi if [ "$DESKTOP_READ_ONLY" = false ] && [ "$PHYSICAL" = true ]; then set -- "$@" --allow-headed; fi SERVERX_BINARY="$PROGRAM_DIR/serverx" SERVERX_CONFIG="$CONFIG" serverx_install_desktop_linux "$@" fi "$PROGRAM_DIR/serverx" install --config "$CONFIG" finish_user_service echo echo "ServerX installed ($INSTALL_KIND mode)." echo "Config: $CONFIG" echo "Check: $BIN_LINK doctor --config $CONFIG" if [ "$INSTALL_KIND" = user ]; then echo "No sudo/root was required. This agent has the permissions of user: $(id -un)." fi