#!/usr/bin/env bash # Skylark feeder installer. # # curl -fsSL https://skylark.watch/feed/install.sh | sudo bash # # Adds Skylark beside whatever already feeds from this receiver. It installs one # small Python forwarder that reads the decoder's aircraft.json (file or # loopback web copy), runs it as its own unprivileged user under systemd, and # pairs it with your station through a code you enter on skylark.watch/pair. # It never installs a decoder, opens the radio, or changes another feeder. # # ... | sudo bash -s -- --pair pair again (the old feed key stops working) # ... | sudo bash -s -- --config-file F use a feeder-config.json from the network page # ... | sudo bash -s -- --source PATH use this aircraft.json instead of detecting one # ... | sudo bash -s -- --uninstall remove everything this installed # # Read it first if you like: every step below prints what it is doing. set -euo pipefail work='' rollback_pending=0 rollback_paths=() rollback_present=() # Everything lives in main() so a download cut short runs nothing at all. main() { local server=${SKYLARK_SERVER:-https://skylark.watch} # SKYLARK_INSTALL_ROOT exists only for the repository's own installer test: # it prefixes every path and records system commands instead of running them. local root=${SKYLARK_INSTALL_ROOT:-} local opt=$root/opt/skylark-feeder etc=$root/etc/skylark-feeder local unit=$root/etc/systemd/system/skylark-feeder.service bin=$root/usr/local/bin/skylark-feeder local user=skylark-feeder mode=install repair=0 config_file='' source='' local_flag=() while [ $# -gt 0 ]; do case $1 in --uninstall) mode=uninstall ;; --pair) repair=1 ;; --config-file) config_file=${2:?--config-file needs a path}; shift ;; --source) source=${2:?--source needs a path or loopback URL}; shift ;; --server) server=${2:?--server needs an origin}; shift ;; -h|--help) sed -n '2,17p' "${BASH_SOURCE[0]:-/dev/null}" 2>/dev/null || true; return 0 ;; *) fail "unknown option $1" ;; esac shift done case $server in https://*) ;; http://127.0.0.1:*) [ -n "$root" ] || fail 'a local server is for the installer test only'; local_flag=(--allow-local-http) ;; *) fail 'the server must be an https:// origin' ;; esac say "Skylark feeder installer" preflight_host "$root" if [ "$mode" = uninstall ]; then uninstall return 0 fi preflight_install "$root" step "Downloading the forwarder from $server" work=$(mktemp -d "${TMPDIR:-/tmp}/skylark-feeder.XXXXXXXXXX") trap cleanup EXIT # The service user must traverse staging to read the public candidate. Keep # credential imports and rollback copies in a separate root-only directory. chmod 0755 "$work" install -d -m 0700 "$work/private" fetch "$server/downloads/feeder-manifest.json" "$work/manifest.json" fetch "$server/downloads/skylark_feed.py" "$work/skylark_feed.py" chmod 0644 "$work/skylark_feed.py" local lab_files=(receiver_lab.py lab/index.html lab/lab.css lab/lab.js lab/model.mjs) install -d -m 0755 "$work/lab" local lab_file for lab_file in "${lab_files[@]}"; do fetch "$server/feed/source/$lab_file" "$work/$lab_file" chmod 0644 "$work/$lab_file" done local version version=$(python3 - "$work/manifest.json" "$work/skylark_feed.py" <<'PY' import hashlib, json, sys manifest = json.load(open(sys.argv[1])) data = open(sys.argv[2], 'rb').read() if hashlib.sha256(data).hexdigest() != manifest['sha256'] or len(data) != manifest['bytes']: sys.exit('the download does not match its manifest; nothing was installed') from pathlib import Path base = Path(sys.argv[2]).parent entries = {item['name']: item for item in manifest['sourceBundle']['files']} for name in ('receiver_lab.py', 'lab/index.html', 'lab/lab.css', 'lab/lab.js', 'lab/model.mjs'): item = entries[name] data = (base / name).read_bytes() if hashlib.sha256(data).hexdigest() != item['sha256'] or len(data) != item['bytes']: sys.exit('Receiver Lab download failed verification; nothing was installed') print(manifest['version']) PY ) || fail 'the forwarder download did not verify; nothing was installed' ok "forwarder $version, checksum verified" # Validate the candidate before touching a working installation. Detection and # pairing below use this staged program, never a half-upgraded live copy. python3 - "$work/skylark_feed.py" <<'PYCODE' import ast, sys ast.parse(open(sys.argv[1]).read()) PYCODE if [ -n "${BASH_SOURCE[0]:-}" ] && [ -f "${BASH_SOURCE[0]}" ]; then install -m 0644 "${BASH_SOURCE[0]}" "$work/install.sh" else fetch "$server/feed/install.sh" "$work/install.sh" fi bash -n "$work/install.sh" write_unit >"$work/service" write_helper >"$work/helper" sh -n "$work/helper" if ! system id -u "$user"; then system useradd --system --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin "$user" ok "created the $user system user" fi step "Looking for your decoder" local detected detected=$(as_service python3 "$work/skylark_feed.py" --detect | python3 -c ' import json, sys sources = json.load(sys.stdin)["sources"] for s in sources: if s["usable"]: print("ok|%s|%d aircraft, %d direct positions right now" % (s["source"], s["aircraft"], s["directPositions"])) else: print("no|%s|%s" % (s["source"], s.get("problem", "not usable"))) ') local chosen='' while IFS='|' read -r state where detail; do [ -n "$state" ] || continue if [ "$state" = ok ] && [ -z "$chosen" ]; then chosen=$where; ok "$where: $detail" elif [ "$state" = ok ]; then note "also found $where" else warn "$where: $detail"; fi done <<<"$detected" if [ -n "$source" ]; then chosen=$source note "using $source as you asked" validate_source "$source" if ! as_service python3 "$work/skylark_feed.py" --check --source "$source"; then warn 'the requested source is not usable by the service user yet; the doctor below explains the decoder check' fi elif [ -z "$chosen" ]; then warn 'no running decoder found. Skylark needs readsb or dump1090-fa (or an adsb.im / ultrafeeder image) on this machine.' note 'The forwarder will keep looking, so it starts sending as soon as a decoder appears.' fi # Device pairing rotates the remote key: never restore that old credential. # Importing a file does not rotate it, so include its prior config in rollback. rollback_paths=("$opt/skylark_feed.py" "$opt/install.sh" "$unit" "$bin") for lab_file in "${lab_files[@]}"; do rollback_paths+=("$opt/$lab_file"); done if [ -n "$config_file" ] || { [ -n "$source" ] && [ "$repair" = 0 ] && [ -f "$etc/config.json" ]; }; then rollback_paths+=("$etc/config.json") fi local i for i in "${!rollback_paths[@]}"; do if [ -f "${rollback_paths[$i]}" ]; then cp -p "${rollback_paths[$i]}" "$work/private/previous-$i" rollback_present[$i]=1 else rollback_present[$i]=0; fi done step "Pairing with your station" install -d -m 0700 "$etc" if [ -n "$config_file" ]; then [ -f "$config_file" ] || fail "no such file $config_file" install -m 0600 "$config_file" "$work/private/config.json" [ -z "$source" ] || override_source "$work/private/config.json" "$source" validate_config "$work/private/config.json" rollback_pending=1 replace_config "$work/private/config.json" "$etc/config.json" ok "imported $config_file" elif [ -f "$etc/config.json" ] && [ "$repair" = 0 ]; then ok 'keeping the existing pairing (use --pair to pair again)' if [ -n "$source" ]; then install -m 0600 "$etc/config.json" "$work/private/config.json" override_source "$work/private/config.json" "$source" validate_config "$work/private/config.json" rollback_pending=1 replace_config "$work/private/config.json" "$etc/config.json" ok 'updated the decoder source, keeping the feed key' fi else local pair_args=(--pair --config "$etc/config.json" --server "$server" ${local_flag[@]+"${local_flag[@]}"}) [ "$repair" = 1 ] && pair_args+=(--force) [ -n "$source" ] && pair_args+=(--source "$source") note "No station yet? Create one at $server/network first; the code below lasts ten minutes." python3 "$work/skylark_feed.py" "${pair_args[@]}" || fail 'pairing did not finish. Run the installer again for a fresh code.' fi system chown -R "$user:$user" "$etc" chmod 0700 "$etc" chmod 0600 "$etc/config.json" validate_config "$etc/config.json" step "Installing and starting the service" rollback_pending=1 install -d -m 0755 "$opt" install -d -m 0755 "$opt/lab" for lab_file in "${lab_files[@]}"; do install -m 0644 "$work/$lab_file" "$opt/$lab_file"; done install -m 0644 "$work/skylark_feed.py" "$opt/skylark_feed.py" install -m 0644 "$work/install.sh" "$opt/install.sh" install -d -m 0755 "$(dirname "$unit")" "$(dirname "$bin")" install -m 0644 "$work/service" "$unit" install -m 0755 "$work/helper" "$bin" system systemctl daemon-reload system systemctl enable skylark-feeder system systemctl restart skylark-feeder [ -n "$root" ] || sleep 4 system systemctl is-active --quiet skylark-feeder || fail 'the new service did not stay running; restoring the previous software' rollback_pending=0 ok 'skylark-feeder is running and starts on boot' note 'Service startup is verified; the doctor below checks uploads separately (a quiet receiver can have no positions).' step "Checking the whole chain" [ -n "$root" ] || sleep 4 as_service python3 "$opt/skylark_feed.py" --config "$etc/config.json" --doctor --wait ${local_flag[@]+"${local_flag[@]}"} || true say '' say " Your station: $server/station.html" say ' Health check: sudo skylark-feeder doctor' say ' Live log: sudo skylark-feeder logs' say ' Remove: sudo skylark-feeder uninstall' say '' } validate_config() { python3 - "$work/skylark_feed.py" "$1" "${SKYLARK_INSTALL_ROOT:-}" <<'PYCODE' import importlib.util, pathlib, sys spec = importlib.util.spec_from_file_location('skylark_candidate', sys.argv[1]) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) try: module.config(pathlib.Path(sys.argv[2]), bool(sys.argv[3])) except Exception: sys.exit('Configuration invalid; check endpoint, source and credential without sharing the file.') PYCODE } validate_source() { python3 - "$work/skylark_feed.py" "$1" <<'PYCODE' import importlib.util, sys spec = importlib.util.spec_from_file_location('skylark_candidate', sys.argv[1]) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) try: source = sys.argv[2] module.source_setting({'aircraftUrl' if source.startswith('http://') else 'aircraftFile': source}) except Exception: sys.exit('Source invalid; use an absolute local file or a loopback HTTP .json URL.') PYCODE } override_source() { python3 - "$work/skylark_feed.py" "$1" "$2" <<'PYCODE' import importlib.util, json, pathlib, sys spec = importlib.util.spec_from_file_location('skylark_candidate', sys.argv[1]) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) try: path, source = pathlib.Path(sys.argv[2]), sys.argv[3] value = json.loads(path.read_text()) if not isinstance(value, dict): raise ValueError('not a configuration object') value.pop('aircraftFile', None) value.pop('aircraftUrl', None) value['aircraftUrl' if source.startswith('http://') else 'aircraftFile'] = source module.source_setting(value) module.write_private(path, value) except Exception: sys.exit('Configuration/source invalid; the existing pairing was not changed.') PYCODE } replace_config() { python3 - "$work/skylark_feed.py" "$1" "$2" <<'PYCODE' import importlib.util, json, pathlib, sys spec = importlib.util.spec_from_file_location('skylark_candidate', sys.argv[1]) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) module.write_private(pathlib.Path(sys.argv[3]), json.loads(pathlib.Path(sys.argv[2]).read_text())) PYCODE } cleanup() { local result=$? i restore_failed=0 if [ "$rollback_pending" = 1 ]; then warn 'Restoring the previous installation; device-paired credentials are kept, imported configuration is restored.' for i in "${!rollback_paths[@]}"; do if [ "${rollback_present[$i]}" = 1 ]; then if ! cp -p "$work/private/previous-$i" "${rollback_paths[$i]}"; then warn "Could not restore ${rollback_paths[$i]}; recovery files are retained in $work/private." restore_failed=1 fi else if ! rm -f "${rollback_paths[$i]}"; then warn "Could not remove ${rollback_paths[$i]} during rollback." restore_failed=1 fi fi done if [ "$restore_failed" = 1 ]; then warn 'Rollback is incomplete. Do not restart until the retained files have been restored.' return "$result" fi system systemctl daemon-reload || warn 'The previous files were restored, but systemd reload failed.' if [ "${rollback_present[2]}" = 1 ]; then system systemctl restart skylark-feeder || true system systemctl is-active --quiet skylark-feeder || warn 'The restored service needs attention; run sudo skylark-feeder status.' else system systemctl disable --now skylark-feeder || true; fi fi rm -rf "$work" return "$result" } say() { printf '%s\n' "$*"; } step() { printf '\n\033[1m%s\033[0m\n' "$*"; } ok() { printf ' \342\234\223 %s\n' "$*"; } note() { printf ' \302\267 %s\n' "$*"; } warn() { printf ' ! %s\n' "$*"; } fail() { printf '\n \342\234\227 %s\n' "$*" >&2; exit 1; } # System changes go through here so the installer test can record them. system() { if [ -n "${SKYLARK_INSTALL_ROOT:-}" ]; then printf '%s\n' "$*" >>"$SKYLARK_INSTALL_ROOT/.system-calls" if [ "$*" = 'systemctl is-active --quiet skylark-feeder' ] && [ -f "$SKYLARK_INSTALL_ROOT/.fail-service-start" ]; then return 1; fi case $1 in id) [ -f "$SKYLARK_INSTALL_ROOT/.user-exists" ] ;; useradd) : >"$SKYLARK_INSTALL_ROOT/.user-exists" ;; *) : ;; esac return fi "$@" >/dev/null 2>&1 } as_service() { if [ -n "${SKYLARK_INSTALL_ROOT:-}" ]; then "$@"; return; fi runuser -u skylark-feeder -- "$@" } preflight_host() { local root=$1 if [ -z "$root" ]; then [ "$(id -u)" = 0 ] || fail 'run it with sudo: curl -fsSL https://skylark.watch/feed/install.sh | sudo bash' [ "$(uname -s)" = Linux ] || fail 'the feeder runs on Linux (Raspberry Pi OS, Debian, Ubuntu, DietPi)' [ -d /run/systemd/system ] || fail 'this system does not run systemd, so the service cannot be installed' fi } preflight_install() { local root=$1 [ -n "$root" ] || command -v runuser >/dev/null || fail 'runuser is missing (util-linux)' command -v python3 >/dev/null || fail 'Python 3 is missing: sudo apt-get install -y python3' python3 -c 'import sys; sys.exit(sys.version_info < (3, 9))' || fail "Python 3.9 or later is needed; this is $(python3 -V 2>&1)" if ! command -v curl >/dev/null && ! command -v wget >/dev/null; then fail 'curl or wget is needed'; fi ok "$(python3 -V 2>&1) on $(uname -m)" } fetch() { if command -v curl >/dev/null; then curl -fsSL --proto '=https,http' --max-time 30 -o "$2" "$1" else wget -q -T 30 -O "$2" "$1"; fi } uninstall() { local root=${SKYLARK_INSTALL_ROOT:-} step "Removing the Skylark feeder" system systemctl disable --now skylark-feeder || true rm -f "$root/etc/systemd/system/skylark-feeder.service" "$root/usr/local/bin/skylark-feeder" system systemctl daemon-reload || true # The config holds this receiver's feed key, so it goes too. rm -rf "$root/opt/skylark-feeder" "$root/etc/skylark-feeder" if system id -u skylark-feeder; then system userdel skylark-feeder || true; fi ok 'removed the service, forwarder, config and user' note 'Your station still exists on skylark.watch. Pause or delete it from your workspace.' note 'Nothing else on this machine was touched: your decoder and other feeders keep running.' } # Keep in step with infra/skylark-feeder.service (tests/feeder-install.test.mjs compares them). write_unit() { cat <<'UNIT' [Unit] Description=Skylark direct ADS-B forwarder After=network-online.target Wants=network-online.target [Service] Type=simple User=skylark-feeder Group=skylark-feeder ExecStart=/usr/bin/python3 /opt/skylark-feeder/skylark_feed.py --config /etc/skylark-feeder/config.json Restart=on-failure RestartSec=15 RestartPreventExitStatus=2 3 NoNewPrivileges=true PrivateTmp=true PrivateDevices=true ProtectSystem=strict ProtectHome=true ProtectKernelTunables=true ProtectKernelModules=true ProtectControlGroups=true RestrictSUIDSGID=true RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX CapabilityBoundingSet= UMask=0077 MemoryMax=96M [Install] WantedBy=multi-user.target UNIT } write_helper() { cat <<'HELPER' #!/bin/sh # skylark-feeder: look after the Skylark forwarder on this receiver. set -eu PY=/opt/skylark-feeder/skylark_feed.py CONF=/etc/skylark-feeder/config.json need_root() { [ "$(id -u)" = 0 ] || { echo "Run it with sudo: sudo skylark-feeder $1" >&2; exit 1; }; } as_service() { runuser -u skylark-feeder -- "$@"; } command=${1:-status} [ $# -gt 0 ] && shift case $command in status) need_root status systemctl --no-pager --lines=0 status skylark-feeder 2>/dev/null | sed -n '1,3p' || true echo exec runuser -u skylark-feeder -- python3 "$PY" --config "$CONF" --doctor ;; lab) need_root lab; exec runuser -u skylark-feeder -- python3 "$PY" --config "$CONF" --lab "$@" ;; doctor) need_root doctor; exec runuser -u skylark-feeder -- python3 "$PY" --config "$CONF" --doctor "$@" ;; detect) need_root detect; exec runuser -u skylark-feeder -- python3 "$PY" --detect ;; logs) exec journalctl -u skylark-feeder -n 40 -f ;; pair) need_root pair python3 "$PY" --config "$CONF" --pair --force "$@" chown skylark-feeder:skylark-feeder "$CONF" systemctl restart skylark-feeder echo "Paired. The service restarted with the new key." ;; restart) need_root restart; systemctl restart skylark-feeder; echo "Restarted." ;; upgrade) need_root upgrade; curl -fsSL https://skylark.watch/feed/install.sh | bash ;; uninstall) need_root uninstall; exec bash /opt/skylark-feeder/install.sh --uninstall ;; version) exec python3 "$PY" --version ;; *) cat < status service state and a full health check (default) doctor health check from the decoder to the public map detect list the decoder outputs on this machine logs follow the forwarder's log pair pair with a station again (replaces the feed key) restart restart the forwarder upgrade install the latest forwarder, keeping the pairing uninstall remove the forwarder, its config and its user version print the forwarder version USAGE [ "$command" = help ] || [ "$command" = --help ] || exit 2 ;; esac HELPER } main "$@"