diff --git a/agent/__main__.py b/agent/__main__.py index bd2b167..627fc21 100644 --- a/agent/__main__.py +++ b/agent/__main__.py @@ -4,6 +4,10 @@ systemd unit'i de aynı komutu çağırır. Buradaki tek iş bağlamı kurup döngüye devretmek ve açılış hatalarını anlaşılır bir mesaja çevirmek; iş mantığı agent.core içindedir. + +İki mod var: + python -m agent servisi çalıştırır (systemd bunu kullanır) + python -m agent --verify bağlantıyı sınar ve çıkar (install.sh + teşhis) """ from __future__ import annotations @@ -13,6 +17,7 @@ from agent.core import loop from agent.core.config import ConfigError, ConfigLoader from agent.core.state import SingleWriterLock, StateStore +from agent.core.verify import verify from agent.logsources.linux_journald import JournaldSource # Çıkış kodları. systemd Restart=on-failure ile çalıştığı için sıfırdan farklı @@ -20,23 +25,41 @@ EXIT_OK = 0 EXIT_CONFIG_ERROR = 1 EXIT_ALREADY_RUNNING = 2 +EXIT_VERIFY_FAILED = 3 + +VERIFY_FLAG = "--verify" + +USAGE = """Kullanım: + python -m agent agent servisini çalıştırır + python -m agent --verify collector bağlantısını sınar, sonra çıkar +""" + +def main(argv: list[str] | None = None) -> int: + args = list(sys.argv[1:] if argv is None else argv) + + if args and args != [VERIFY_FLAG]: + print(USAGE, file=sys.stderr, flush=True) + return EXIT_CONFIG_ERROR -def main() -> int: loader = ConfigLoader() - store = StateStore() - # Tek platform seçimi buradadır: Windows desteği geldiğinde değişecek satır - # bu, döngü değil. - log_source = JournaldSource() try: - # Döngüden önce bir kez okunur: yapılandırma hatalı ya da eksikse agent - # hiç açılmasın, yanlış ayarla çalışmasın. - loader.load() + # Her iki modda da config önce okunur: yapılandırma hatalı ya da eksikse + # agent hiç açılmasın, yanlış ayarla çalışmasın. + config = loader.load() except ConfigError as exc: print(f"config hatası: {exc}", file=sys.stderr, flush=True) return EXIT_CONFIG_ERROR + if args == [VERIFY_FLAG]: + return _run_verify(config) + + store = StateStore() + # Tek platform seçimi buradadır: Windows desteği geldiğinde değişecek satır + # bu, döngü değil. + log_source = JournaldSource() + try: # Kilit tüm çalışma boyunca tutulur; süreç bittiğinde bırakılır. with SingleWriterLock(store.directory): @@ -51,5 +74,21 @@ def main() -> int: return EXIT_OK +def _run_verify(config) -> int: + """Bağlantı testini çalıştırır ve sonucu kullanıcıya basar. + + Başarısızlık stderr'e yazılır: install.sh çıktıyı ayırt edebilsin ve + kullanıcı hatayı boru hattında kaybetmesin. + """ + result = verify(config) + + if result.ok: + print(f"✓ Kuruldu ve bağlandı — {result.detail}", flush=True) + return EXIT_OK + + print(f"✗ Bağlanamadı — {result.detail}", file=sys.stderr, flush=True) + return EXIT_VERIFY_FAILED + + if __name__ == "__main__": sys.exit(main()) diff --git a/agent/core/config.py b/agent/core/config.py index 3cdba29..bd6b1bb 100644 --- a/agent/core/config.py +++ b/agent/core/config.py @@ -7,6 +7,7 @@ from __future__ import annotations import os +import stat import tomllib from dataclasses import dataclass from pathlib import Path @@ -21,6 +22,13 @@ # değer yazılırsa sessizce yok sayılmaz: floor uygulanır ve uyarı basılır. MIN_SEND_INTERVAL_SECONDS = 10 +# config.toml düz `device_key`i barındırır. Bu bitlerden herhangi biri açıksa +# dosyayı sahibinden BAŞKASI da okuyabiliyor demektir; makinedeki başka bir +# yerel kullanıcı anahtarı alıp cihazı taklit edebilir. +INSECURE_PERMISSION_BITS = ( + stat.S_IRGRP | stat.S_IWGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IWOTH | stat.S_IXOTH +) + # Config'de bulunması ZORUNLU alanlar. Eksikse agent açılışta durur; varsayılan # uydurmak, yanlış adrese veri göndermeye çalışan bir agent üretirdi. REQUIRED_KEYS = ("collector_url", "device_key") @@ -72,6 +80,26 @@ def config_path() -> Path: return Path(override) if override else DEFAULT_CONFIG_PATH +def check_permissions(path: Path, mode: int, *, warn) -> bool: + """Config dosyasının izin bitlerini denetler; gevşekse uyarı basar. + + Agent DURDURULMAZ. Çalışan bir izleme aracını izin biti yüzünden öldürmek, + makineyi tamamen gözsüz bırakır — yani çözdüğünden büyük bir sorun yaratır. + Uyarı journald'a düşer ve `systemctl status tracebox-agent` ile görülür. + + Dönen değer: izinler güvenliyse True. + """ + if not stat.S_IMODE(mode) & INSECURE_PERMISSION_BITS: + return True + + warn( + f"{path} izinleri fazla açık ({stat.filemode(mode)}); cihaz anahtarını " + f"bu makinedeki başka kullanıcılar okuyabilir. Düzeltmek için: " + f"chmod 600 {path}" + ) + return False + + def _positive_int(raw: dict, key: str, default: int) -> int: """raw[key]'i pozitif tam sayı olarak okur; yoksa default'a düşer. @@ -142,7 +170,7 @@ def __init__(self, path: Path | None = None, *, warn=print) -> None: self._path = path if path is not None else config_path() self._warn = warn self._cached: Config | None = None - self._signature: tuple[float, int] | None = None + self._signature: tuple[float, int, int] | None = None @property def path(self) -> Path: @@ -157,13 +185,20 @@ def load(self) -> Config: korunur ve uyarı basılır; çalışan agent bir yazım hatası yüzünden ölmez. """ try: - stat = self._path.stat() - signature = (stat.st_mtime, stat.st_size) + info = self._path.stat() + # İzin bitleri de imzanın parçası: `chmod` mtime'ı da boyutu da + # değiştirmez, yani izinler yalnızca burada takip edilirse sonradan + # gevşetilen bir dosya fark edilmeden kalırdı. + signature = (info.st_mtime, info.st_size, info.st_mode) # Dosya son okumadan beri değişmediyse ayrıştırmayı atla. if self._cached is not None and signature == self._signature: return self._cached + # İzin denetimi yalnızca dosya (yeniden) okunurken çalışır; her + # tick'te uyarı basılsaydı log'u doldururdu. + check_permissions(self._path, info.st_mode, warn=self._warn) + with self._path.open("rb") as handle: raw = tomllib.load(handle) diff --git a/agent/core/verify.py b/agent/core/verify.py new file mode 100644 index 0000000..b9e8d94 --- /dev/null +++ b/agent/core/verify.py @@ -0,0 +1,92 @@ +""" +Bağlantı testi — GET /verify. + +İki yerde kullanılır: `install.sh`'in son adımı ve kullanıcının sonradan elle +çalıştırabileceği teşhis komutu (`python -m agent --verify`). + +Tek soruyu cevaplar: bu config ile collector'a ulaşılıyor ve anahtar kabul +ediliyor mu? Hiçbir şey yazmaz, hiçbir durum değiştirmez. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import httpx + +VERIFY_PATH = "/verify" + +# Kurulumun sonunda çalıştığı için kısa tutulur: kullanıcı terminalin başında +# bekliyor, dakikalarca asılı kalmamalı. +REQUEST_TIMEOUT_SECONDS = 10.0 + +# İsteğin hiç yanıt üretemediği durumlar. httpx.InvalidURL ayrıca sayılmak +# zorunda: HTTPError'un ALTINDA değildir, yani elle yazılmış bozuk bir +# collector_url yakalanmadan geçip kullanıcıya traceback gösterirdi. +NETWORK_ERRORS = (httpx.HTTPError, httpx.InvalidURL) + + +@dataclass(frozen=True) +class VerifyResult: + """Testin sonucu ve kullanıcıya gösterilecek tek satırlık açıklama.""" + + ok: bool + detail: str + + +def verify(config) -> VerifyResult: + """`GET /verify` isteğini atar ve sonucu okunabilir bir mesaja çevirir. + + Durum kodları ayrı ayrı ele alınır çünkü kullanıcının atacağı adım her + birinde farklıdır: 401 anahtarı, ulaşılamama adresi/ağı, diğer kodlar + collector'ın kendisini işaret eder. + """ + url = f"{config.collector_url.rstrip('/')}{VERIFY_PATH}" + headers = {"Authorization": f"Bearer {config.device_key}"} + + try: + response = httpx.get(url, headers=headers, timeout=REQUEST_TIMEOUT_SECONDS) + except NETWORK_ERRORS as error: + return VerifyResult( + ok=False, + detail=( + f"collector'a ulaşılamadı ({type(error).__name__}) — " + f"collector_url doğru mu, makinenin internet erişimi var mı?" + ), + ) + + if response.status_code == 200: + return VerifyResult(ok=True, detail=_describe(response)) + + if response.status_code == 401: + return VerifyResult( + ok=False, + detail=( + "cihaz anahtarı reddedildi (401) — config.toml'daki device_key, " + "dashboard'un verdiği anahtarla aynı mı?" + ), + ) + + return VerifyResult( + ok=False, + detail=f"collector beklenmeyen yanıt verdi (HTTP {response.status_code})", + ) + + +def _describe(response: httpx.Response) -> str: + """200 yanıtından cihaz adı ve collector sürümünü çıkarır. + + Gövde beklenen biçimde değilse test yine BAŞARILI sayılır: doğrulanan şey + anahtarın kabul edilmesidir, yanıtın şekli değil. + """ + try: + body = response.json() + except ValueError: + return "bağlantı kuruldu" + + if not isinstance(body, dict): + return "bağlantı kuruldu" + + device_name = body.get("device_name") or "?" + version = body.get("version") or "?" + return f"cihaz: {device_name} · collector sürümü: {version}" diff --git a/agent/install.sh b/agent/install.sh new file mode 100755 index 0000000..cbef2b3 --- /dev/null +++ b/agent/install.sh @@ -0,0 +1,376 @@ +#!/usr/bin/env bash +# +# TraceBox Agent — kurulum. +# +# Kullanım (root gerekir): +# curl -fsSL https://raw.githubusercontent.com/Denisergocmen924/TraceBox/master/agent/install.sh -o install.sh +# less install.sh # çalıştırmadan önce okuyun +# sudo bash install.sh +# +# Betik tek başına indirilebilir; agent kaynağını kendisi çeker. +# +# Cihaz anahtarı komut satırından DEĞİL, çalışma sırasında sorularak alınır: +# argüman olarak verilse kabuk geçmişine ve `ps` çıktısına düşerdi. + +set -Eeuo pipefail + +# --- Ayarlar --------------------------------------------------------------- +# Fork ya da farklı bir dal ile kurmak için: TRACEBOX_REPO / TRACEBOX_REF. +REPO="${TRACEBOX_REPO:-Denisergocmen924/TraceBox}" +REF="${TRACEBOX_REF:-master}" +SOURCE_URL="https://github.com/${REPO}/archive/refs/heads/${REF}.tar.gz" + +DEFAULT_COLLECTOR_URL="https://tracebox-collector.fly.dev" +KEY_PREFIX="tbx_live_" + +INSTALL_DIR="/opt/tracebox" +VENV_DIR="${INSTALL_DIR}/venv" +CONFIG_DIR="/etc/tracebox" +CONFIG_FILE="${CONFIG_DIR}/config.toml" +STATE_DIR="/var/lib/tracebox" +SERVICE_USER="tracebox" +SERVICE_NAME="tracebox-agent.service" +UNIT_PATH="/etc/systemd/system/${SERVICE_NAME}" +TTY_DEVICE="/dev/tty" + +# --- Çıktı yardımcıları ---------------------------------------------------- + +step() { printf '\n▸ %s\n' "$*"; } +say() { printf ' %s\n' "$*"; } +warn() { printf ' ! %s\n' "$*" >&2; } +have() { command -v "$1" >/dev/null 2>&1; } + +# --- Geri alma (rollback) -------------------------------------------------- +# Yarım kurulum bırakılmaz: oluşturulan her şey bir geri alma komutu olarak +# kaydedilir ve hata durumunda TERS sırayla çalıştırılır. + +ROLLBACK=() +ROLLBACK_ENABLED=1 +WORK_DIR="" + +add_rollback() { ROLLBACK+=("$1"); } + +run_rollback() { + (( ROLLBACK_ENABLED )) || return 0 + (( ${#ROLLBACK[@]} )) || return 0 + printf '\n yarım kalan kurulum geri alınıyor...\n' >&2 + local i + for (( i = ${#ROLLBACK[@]} - 1; i >= 0; i-- )); do + eval "${ROLLBACK[i]}" >/dev/null 2>&1 || true + done + printf ' sistem kurulum öncesi haline döndürüldü.\n' >&2 +} + +fail() { + printf '\n✗ %s\n' "$*" >&2 + run_rollback + (( ROLLBACK_ENABLED )) || printf '\n Yapılandırma korundu. Kaldırmak için: sudo %s/uninstall.sh\n' "${INSTALL_DIR}" >&2 + exit 1 +} + +on_error() { fail "Kurulum ${1} numaralı satırda başarısız oldu."; } +trap 'on_error ${LINENO}' ERR + +# İndirme için açılan geçici dizin her durumda silinir. +trap '[[ -n "${WORK_DIR}" && -d "${WORK_DIR}" ]] && rm -rf "${WORK_DIR}" || true' EXIT + +# --- Terminalden okuma ----------------------------------------------------- +# Girdiler stdin'den DEĞİL doğrudan terminalden okunur; böylece betik +# `curl ... | sudo bash` ile boruya bağlı çalıştırıldığında da sorular sorulabilir +# (o durumda stdin betiğin kendi metnidir). + +prompt_default() { + local label="$1" default="$2" value="" + printf ' %s [%s]: ' "${label}" "${default}" > "${TTY_DEVICE}" + read -r value < "${TTY_DEVICE}" + printf '%s' "${value:-${default}}" +} + +prompt_secret() { + local label="$1" value="" + while [[ -z "${value}" ]]; do + printf ' %s: ' "${label}" > "${TTY_DEVICE}" + # -s: yazılan karakterler ekranda görünmez (omuz üstünden okunmasın). + read -r -s value < "${TTY_DEVICE}" + printf '\n' > "${TTY_DEVICE}" + [[ -n "${value}" ]] || printf ' boş olamaz, tekrar deneyin\n' > "${TTY_DEVICE}" + done + printf '%s' "${value}" +} + +ask_yes_no() { + local answer="" + printf ' %s [e/H] ' "$1" > "${TTY_DEVICE}" + read -r answer < "${TTY_DEVICE}" + [[ "${answer}" == "e" || "${answer}" == "E" ]] +} + +run_as_service_user() { + if have runuser; then + runuser -u "${SERVICE_USER}" -- "$@" + else + su -s /bin/sh "${SERVICE_USER}" -c "$(printf '%q ' "$@")" + fi +} + +printf '\nTraceBox Agent kurulumu\n' + +# =========================================================================== +# 1/7 Ön kontrol — eksik bir şey varsa HİÇBİR ŞEY oluşturmadan dur. +# =========================================================================== +step "1/7 Ön kontrol" + +[[ ${EUID} -eq 0 ]] || fail "root yetkisi gerekli. Şöyle çalıştırın: sudo bash install.sh" +[[ "$(uname -s)" == "Linux" ]] || fail "TraceBox Agent yalnızca Linux'ta çalışır (bulunan: $(uname -s))." + +# systemd'nin init olarak çalıştığının standart göstergesi bu dizindir. +[[ -d /run/systemd/system ]] || fail "systemd bulunamadı; agent bir systemd servisi olarak çalışır." +have systemctl || fail "systemctl bulunamadı." +have journalctl || fail "journalctl bulunamadı; agent sistem loglarını journald'dan okur." +have useradd || fail "useradd bulunamadı; yetkisiz servis kullanıcısı oluşturulamaz." +have tar || fail "tar bulunamadı; kaynak arşivi açılamaz." +[[ -e "${TTY_DEVICE}" ]] || fail "Terminal erişimi yok. Cihaz anahtarı sorularak alınır; betiği bir terminalden çalıştırın." + +if have curl; then + DOWNLOADER="curl" +elif have wget; then + DOWNLOADER="wget" +else + fail "curl veya wget gerekli; agent kaynağı indirilemez." +fi + +have python3 || fail "python3 bulunamadı; en az 3.11 gerekli." +python3 -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' \ + || fail "python3 sürümü çok eski ($(python3 -V 2>&1)); en az 3.11 gerekli." + +# venv ve ensurepip Debian/Ubuntu'da ayrı bir pakettedir; eksikse izole ortam +# kurulamaz ve bu ancak 4. adımda patlardı. +python3 -c 'import venv, ensurepip' >/dev/null 2>&1 \ + || fail "python3 venv modülü eksik. Debian/Ubuntu'da: sudo apt install python3-venv" + +if [[ -d "${INSTALL_DIR}" || -e "${UNIT_PATH}" ]]; then + say "mevcut kurulum bulundu — kod ve servis yenilenecek" +fi + +say "ortam uygun ($(python3 -V 2>&1), ${DOWNLOADER})" + +# =========================================================================== +# 2/7 Kaynak — betik tek başına indirildiği için agent kodunu kendisi çeker. +# =========================================================================== +step "2/7 Agent kaynağı indiriliyor" + +WORK_DIR="$(mktemp -d)" +TARBALL="${WORK_DIR}/source.tar.gz" +SOURCE_ROOT="${WORK_DIR}/src" + +say "${REPO} @ ${REF}" +case "${DOWNLOADER}" in + curl) + # --proto '=https': yönlendirme düz HTTP'ye düşerse indirme reddedilir. + curl -fsSL --proto '=https' --tlsv1.2 -o "${TARBALL}" "${SOURCE_URL}" \ + || fail "Kaynak indirilemedi: ${SOURCE_URL}" + ;; + wget) + wget -q --https-only -O "${TARBALL}" "${SOURCE_URL}" \ + || fail "Kaynak indirilemedi: ${SOURCE_URL}" + ;; +esac + +mkdir -p "${SOURCE_ROOT}" +# --strip-components=1: arşivin en üstündeki "TraceBox-master/" sarmalı atılır. +tar -xzf "${TARBALL}" -C "${SOURCE_ROOT}" --strip-components=1 \ + || fail "Kaynak arşivi açılamadı." + +SOURCE_AGENT="${SOURCE_ROOT}/agent" +for required in "__main__.py" "requirements.txt" "tracebox-agent.service"; do + [[ -f "${SOURCE_AGENT}/${required}" ]] \ + || fail "İndirilen arşiv eksik: agent/${required} yok." +done + +say "indirildi ve açıldı" + +# =========================================================================== +# 3/7 Kullanıcı, dizinler ve kodun yerleştirilmesi +# =========================================================================== +step "3/7 Kullanıcı ve dizinler" + +if id "${SERVICE_USER}" >/dev/null 2>&1; then + say "kullanıcı zaten var: ${SERVICE_USER}" +else + NOLOGIN_SHELL="$(command -v nologin || true)" + [[ -n "${NOLOGIN_SHELL}" ]] || NOLOGIN_SHELL="/bin/false" + # --system: normal kullanıcı aralığının dışında bir UID; --no-create-home: + # ev dizini yok. Bu hesapla oturum açılamaz, yalnızca servis çalışır. + useradd --system --no-create-home --shell "${NOLOGIN_SHELL}" "${SERVICE_USER}" + add_rollback "userdel ${SERVICE_USER}" + say "yetkisiz kullanıcı oluşturuldu: ${SERVICE_USER}" +fi + +# journald'ı okuyabilmek için gereken TEK ek yetki. root değil, grup üyeliği. +if getent group systemd-journal >/dev/null 2>&1; then + usermod -aG systemd-journal "${SERVICE_USER}" + say "systemd-journal grubuna eklendi (log okuma izni)" +else + warn "systemd-journal grubu yok; agent yalnızca kendi loglarını görebilir" +fi + +for dir in "${INSTALL_DIR}" "${CONFIG_DIR}" "${STATE_DIR}"; do + if [[ ! -d "${dir}" ]]; then + mkdir -p "${dir}" + add_rollback "rm -rf ${dir}" + say "oluşturuldu: ${dir}" + fi +done + +# Kod her kurulumda sıfırdan kopyalanır: eski sürümden kalan bir dosya +# yenisinin yanında durmasın. +rm -rf "${INSTALL_DIR}/agent" +cp -R "${SOURCE_AGENT}" "${INSTALL_DIR}/agent" +find "${INSTALL_DIR}/agent" -name '__pycache__' -type d -prune -exec rm -rf {} + 2>/dev/null || true +# Kurulum betikleri hedef makinede kod ağacının içinde durmaz. +rm -f "${INSTALL_DIR}/agent/install.sh" "${INSTALL_DIR}/agent/uninstall.sh" + +# Kaldırma betiği bilinen bir yere konur: kullanıcı elle çalıştırabilsin. +if [[ -f "${SOURCE_AGENT}/uninstall.sh" ]]; then + install -m 755 "${SOURCE_AGENT}/uninstall.sh" "${INSTALL_DIR}/uninstall.sh" + say "kaldırma betiği: ${INSTALL_DIR}/uninstall.sh" +fi + +say "kod yerleştirildi: ${INSTALL_DIR}/agent" + +# =========================================================================== +# 4/7 İzole Python ortamı — sistem Python'ına hiç dokunulmaz. +# =========================================================================== +step "4/7 İzole Python ortamı" + +rm -rf "${VENV_DIR}" +python3 -m venv "${VENV_DIR}" || fail "Sanal ortam oluşturulamadı: ${VENV_DIR}" + +"${VENV_DIR}/bin/pip" install --quiet --upgrade pip >/dev/null 2>&1 \ + || warn "pip güncellenemedi; mevcut sürümle devam ediliyor" +"${VENV_DIR}/bin/pip" install --quiet -r "${INSTALL_DIR}/agent/requirements.txt" \ + || fail "Bağımlılıklar kurulamadı (ağ erişimi var mı?)." + +# Bytecode şimdi, root iken üretilir: /opt/tracebox agent'a salt-okunur olduğu +# için servis her açılışta yeniden derlemeye çalışıp başarısız olmasın. +"${VENV_DIR}/bin/python" -m compileall -q "${INSTALL_DIR}/agent" >/dev/null 2>&1 || true + +# Kod root'a ait ve agent'a salt-okunur: agent kendi kodunu değiştiremez. +chown -R root:root "${INSTALL_DIR}" +chmod 755 "${INSTALL_DIR}" + +# Agent'ın YAZDIĞI tek yer: state.json + spool. +chown -R "${SERVICE_USER}:${SERVICE_USER}" "${STATE_DIR}" +chmod 750 "${STATE_DIR}" + +# Config dizini: sahibi root, grubu tracebox, başkalarına kapalı. +chown root:"${SERVICE_USER}" "${CONFIG_DIR}" +chmod 750 "${CONFIG_DIR}" + +say "psutil + httpx kuruldu (sistem Python'ı değişmedi)" + +# =========================================================================== +# 5/7 Yapılandırma — cihaz anahtarı burada sorulur. +# =========================================================================== +step "5/7 Yapılandırma" + +WRITE_CONFIG=1 +if [[ -f "${CONFIG_FILE}" ]]; then + say "mevcut yapılandırma bulundu: ${CONFIG_FILE}" + if ask_yes_no "Korunsun mu? (hayır derseniz cihaz anahtarı yeniden sorulur)"; then + WRITE_CONFIG=0 + say "mevcut yapılandırma korundu" + fi +fi + +if (( WRITE_CONFIG )); then + COLLECTOR_URL="$(prompt_default "Collector adresi" "${DEFAULT_COLLECTOR_URL}")" + DEVICE_KEY="$(prompt_secret "Cihaz anahtarı (dashboard'da bir kez gösterilir)")" + + [[ "${DEVICE_KEY}" == "${KEY_PREFIX}"* ]] \ + || warn "anahtar '${KEY_PREFIX}' ile başlamıyor — doğru değeri yapıştırdığınızdan emin olun" + + # Dosya İÇERİK yazılmadan önce kilitlenir: anahtar bir an bile başkalarının + # okuyabileceği bir dosyada durmasın. + : > "${CONFIG_FILE}" + chown "${SERVICE_USER}:${SERVICE_USER}" "${CONFIG_FILE}" + chmod 600 "${CONFIG_FILE}" + + cat > "${CONFIG_FILE}" </dev/null 2>&1 +systemctl restart "${SERVICE_NAME}" + +say "kuruldu ve başlatıldı: ${SERVICE_NAME}" + +# Servisin ilk saniyede düşüp düşmediğini görmek için kısa bir bekleme. +sleep 2 +if systemctl is-active --quiet "${SERVICE_NAME}"; then + say "servis çalışıyor" +else + warn "servis ayağa kalkmadı — ayrıntı: journalctl -u ${SERVICE_NAME} -n 30 --no-pager" +fi + +# =========================================================================== +# 7/7 Bağlantı testi — GET /verify +# =========================================================================== +step "7/7 Bağlantı testi" + +# Test, servisin çalıştığı kullanıcıyla yapılır: config'i okuyabildiği de +# böylece doğrulanmış olur. +VERIFY_OK=1 +( cd "${INSTALL_DIR}" && run_as_service_user "${VENV_DIR}/bin/python" -m agent --verify ) \ + || VERIFY_OK=0 + +printf '\n' +if (( VERIFY_OK )); then + printf '✓ TraceBox Agent kuruldu ve collector ile konuşuyor.\n\n' +else + printf '! TraceBox Agent kuruldu ama bağlantı testi başarısız.\n' + printf ' Yapılandırmayı düzeltip tekrar deneyin:\n' + printf ' sudo nano %s\n' "${CONFIG_FILE}" + printf ' sudo -u %s %s/bin/python -m agent --verify (%s içinden)\n\n' \ + "${SERVICE_USER}" "${VENV_DIR}" "${INSTALL_DIR}" +fi + +printf ' Durum : systemctl status %s\n' "${SERVICE_NAME}" +printf ' Loglar : journalctl -u %s -f\n' "${SERVICE_NAME}" +printf ' Kaldırma: sudo %s/uninstall.sh\n\n' "${INSTALL_DIR}" diff --git a/agent/tracebox-agent.service b/agent/tracebox-agent.service new file mode 100644 index 0000000..9f54acb --- /dev/null +++ b/agent/tracebox-agent.service @@ -0,0 +1,60 @@ +[Unit] +Description=TraceBox Agent — metrik ve log toplayıp collector'a gönderir +Documentation=https://github.com/denisergocmen/tracebox +# Ağ hazır olmadan başlamak anlamsız değil ama gereksiz: agent zaten +# gönderemediği veriyi spool'da tutar. Yine de ilk gönderimin boşa gitmemesi +# için ağ yapılandırması bittikten sonra başlatılır. +After=network-online.target +Wants=network-online.target + +# Çökme döngüsü freni: 60 saniyelik pencerede 5'ten fazla başlatma denemesi +# olursa systemd servisi durdurur ve `failed` durumunda bırakır. Bu iki ayar +# systemd v230'dan beri [Service] değil [Unit] bölümüne yazılır. +StartLimitIntervalSec=60 +StartLimitBurst=5 + +[Service] +Type=simple + +# Yetkisiz kullanıcı. install.sh bu kullanıcıyı oluşturur ve log okuyabilmesi +# için systemd-journal grubuna ekler. +User=tracebox +Group=tracebox + +WorkingDirectory=/opt/tracebox +# venv'in Python'ı — sistem python3'ü değil. psutil ve httpx yalnızca bu +# yorumlayıcının site-packages'ında kurulu. +ExecStart=/opt/tracebox/venv/bin/python -m agent + +# Çıktı satır satır tamponlanmasın: agent'ın uyarıları journald'a anında +# düşsün, `systemctl status` gecikmeli göstermesin. +Environment=PYTHONUNBUFFERED=1 + +Restart=on-failure +RestartSec=5 + +# --- Yalıtım --- +# Kök dosya sistemi salt-okunur bağlanır; yalnızca aşağıda sayılan yol yazılabilir. +# Agent'ın yazdığı tek yer state.json + spool'un durduğu /var/lib/tracebox'tır. +ProtectSystem=strict +ReadWritePaths=/var/lib/tracebox + +# Kullanıcıların ev dizinleri görünmez, /tmp süreç için ayrı bir alandır. +ProtectHome=yes +PrivateTmp=yes + +# setuid/setgid ile yetki yükseltme yolu kapatılır: süreç başladığı yetkiden +# fazlasını hiçbir şekilde alamaz. +NoNewPrivileges=yes + +# Çekirdek ayarları ve modül yükleme kapalı — agent yalnızca okur, sistemi +# değiştirmez. +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes + +# Yalnızca IP soketleri. Agent'ın konuştuğu tek yer HTTPS üzerinden collector. +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX + +[Install] +WantedBy=multi-user.target diff --git a/agent/uninstall.sh b/agent/uninstall.sh new file mode 100755 index 0000000..4b4bb6a --- /dev/null +++ b/agent/uninstall.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# +# TraceBox Agent — temiz kaldırma. +# +# Servisi durdurur, dosyaları ve yetkisiz kullanıcıyı siler. Cihaz anahtarı +# config.toml içinde durduğu için bu betik anahtarı da yok eder. +# +# İki yerden çağrılır: kullanıcı elle (sudo ./uninstall.sh) ve M6'daki `delete` +# komutu (self-uninstall). O yüzden --yes ile soru sormadan da çalışabilir. + +set -euo pipefail + +SERVICE_NAME="tracebox-agent.service" +UNIT_PATH="/etc/systemd/system/${SERVICE_NAME}" +INSTALL_DIR="/opt/tracebox" +CONFIG_DIR="/etc/tracebox" +STATE_DIR="/var/lib/tracebox" +SERVICE_USER="tracebox" + +say() { printf ' %s\n' "$*"; } +step() { printf '\n▸ %s\n' "$*"; } +fail() { printf '\n✗ %s\n' "$*" >&2; exit 1; } + +# --- Ön koşullar ----------------------------------------------------------- + +[[ ${EUID} -eq 0 ]] || fail "Bu betik root yetkisi ister: sudo ./uninstall.sh" + +# --yes verilmediyse onay iste. Silinen şeyler geri getirilemez (anahtar dahil). +if [[ "${1:-}" != "--yes" ]]; then + printf 'TraceBox Agent kaldırılacak:\n' + printf ' - %s durdurulup devre dışı bırakılacak\n' "${SERVICE_NAME}" + printf ' - %s, %s, %s silinecek\n' "${INSTALL_DIR}" "${CONFIG_DIR}" "${STATE_DIR}" + printf ' - %s kullanıcısı silinecek\n' "${SERVICE_USER}" + printf '\nCihaz anahtarı da silinir; cihazı tekrar eklemek için yeni anahtar gerekir.\n' + read -r -p 'Devam edilsin mi? [e/H] ' answer + [[ "${answer}" == "e" || "${answer}" == "E" ]] || fail "İptal edildi." +fi + +# --- 1) Servisi durdur ----------------------------------------------------- +# Dosyalardan ÖNCE durdurulur: yoksa systemd, kodu silinmiş bir servisi +# Restart=on-failure ile yeniden başlatmayı dener. + +step "Servis durduruluyor" +if command -v systemctl >/dev/null 2>&1; then + systemctl disable --now "${SERVICE_NAME}" >/dev/null 2>&1 || true + say "durduruldu ve devre dışı bırakıldı" +else + say "systemctl yok — atlandı" +fi + +if [[ -f "${UNIT_PATH}" ]]; then + rm -f "${UNIT_PATH}" + say "unit dosyası silindi: ${UNIT_PATH}" +fi + +if command -v systemctl >/dev/null 2>&1; then + systemctl daemon-reload || true + # Durdurulmuş servisin `failed` kaydı systemctl listesinde kalmasın. + systemctl reset-failed "${SERVICE_NAME}" >/dev/null 2>&1 || true +fi + +# --- 2) Dosyaları sil ------------------------------------------------------ + +step "Dosyalar siliniyor" +for path in "${INSTALL_DIR}" "${CONFIG_DIR}" "${STATE_DIR}"; do + if [[ -e "${path}" ]]; then + rm -rf "${path}" + say "silindi: ${path}" + else + say "zaten yok: ${path}" + fi +done + +# --- 3) Kullanıcıyı sil ---------------------------------------------------- +# En sona bırakılır: kullanıcı hâlâ varken dosya sahipliği tutarlı kalır ve +# servis durmadan userdel zaten başarısız olurdu. + +step "Kullanıcı siliniyor" +if id "${SERVICE_USER}" >/dev/null 2>&1; then + if userdel "${SERVICE_USER}" 2>/dev/null; then + say "silindi: ${SERVICE_USER}" + else + say "UYARI: ${SERVICE_USER} silinemedi (kullanıcıya ait süreç kalmış olabilir)" + fi +else + say "zaten yok: ${SERVICE_USER}" +fi + +printf '\n✓ TraceBox Agent kaldırıldı.\n' diff --git a/collector/auth.py b/collector/auth.py index 5ca36a3..ce5fac8 100644 --- a/collector/auth.py +++ b/collector/auth.py @@ -1,32 +1,68 @@ """ -Kimlik doğrulama — cihaz anahtarı modu. +Kimlik doğrulama — iki bağımsız mod. -Cihaz kimliği payload'dan değil ANAHTARDAN türetilir: collector `sha256(key)` -hesaplar, `devices.key_hash` ile eşleştirir ve satırdan `device_id` + -`account_id` alır. +1. **Cihaz anahtarı** (agent → `/inventory`, `/ingest`, `/verify`) + Cihaz kimliği payload'dan değil ANAHTARDAN türetilir: collector `sha256(key)` + hesaplar, `devices.key_hash` ile eşleştirir ve satırdan `device_id` + + `account_id` alır. -User JWT modu (`POST /devices`) M5'te bu modüle eklenecek. +2. **User JWT** (dashboard → `/devices`) + Supabase'in ES256 ile imzaladığı access token, projenin JWKS ucundan çekilen + AÇIK anahtarla doğrulanır. Token'ın `sub` alanı `auth.uid()`, yani + `account_id`'dir. + +İki mod da `Authorization: Bearer …` başlığını okur ama farklı türde bir sır +bekler; bu yüzden ayrı bağımlılık fonksiyonlarıdır ve bir uç yalnızca birini +kullanır. """ from __future__ import annotations +import asyncio +import logging +import time from dataclasses import dataclass from typing import Annotated +import httpx +import jwt from fastapi import Depends, Header, HTTPException, status +from jwt import PyJWK, PyJWKSet +from jwt.exceptions import PyJWTError from hashing import hash_device_key, hashes_match -from supabase_client import SupabaseError, get_client +from supabase_client import SupabaseError, get_client, get_project_url + +logger = logging.getLogger("tracebox.auth") BEARER_PREFIX = "Bearer " -# Tüm başarısız doğrulamalar aynı yanıtı verir: anahtarın var olup olmadığı, -# biçiminin doğru olup olmadığı dışarıya sızmaz. -_UNAUTHORIZED = HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Geçersiz cihaz anahtarı.", - headers={"WWW-Authenticate": "Bearer"}, -) +# Supabase Auth'un taban yolu. Token'ın `iss` alanı bu adresle biter. +AUTH_PATH = "/auth/v1" + +# Açık anahtarların yayınlandığı belge. Bir sır DEĞİLDİR; imzayı doğrulamaya +# yarar, imza atmaya yaramaz. +JWKS_PATH = f"{AUTH_PATH}/.well-known/jwks.json" + +# Kabul edilen tek imza algoritması. Liste `jwt.decode`'a verilir ve token'ın +# başlığında başka bir algoritma yazıyorsa doğrulama reddedilir. +ALLOWED_ALGORITHMS = ["ES256"] + +# Supabase'in oturum açmış kullanıcı için ürettiği token'da sabit olan iki alan. +EXPECTED_AUDIENCE = "authenticated" +EXPECTED_ROLE = "authenticated" + +# Token'da bulunması ZORUNLU alanlar. Eksikse doğrulama başarısız olur; aksi +# halde `exp` taşımayan bir token süresiz geçerli sayılırdı. +REQUIRED_CLAIMS = ["exp", "iss", "aud", "sub"] + +# JWKS belgesi çekilirken beklenecek azami süre. +JWKS_TIMEOUT_SECONDS = 10.0 + +# İki JWKS çekimi arasındaki en kısa süre. Tanınmayan her `kid` anında bir +# çekim tetikleseydi, uydurma `kid`'lerle gönderilen istek seli collector'ı +# Supabase'e istek üretmeye zorlardı; bu sınır o zinciri keser. +JWKS_MIN_REFRESH_SECONDS = 60.0 @dataclass(frozen=True) @@ -39,6 +75,137 @@ class DeviceIdentity: pending_delete: bool +@dataclass(frozen=True) +class UserIdentity: + """Doğrulanmış kullanıcı. + + `account_id`, token'ın `sub` alanıdır ve `accounts.id` ile birebir aynıdır + (şema gereği `accounts.id = auth.users.id`). + """ + + account_id: str + + +# Tüm başarısız doğrulamalar aynı yanıtı verir: anahtarın/token'ın var olup +# olmadığı, biçiminin doğru olup olmadığı dışarıya sızmaz. +_UNAUTHORIZED_DEVICE = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Geçersiz cihaz anahtarı.", + headers={"WWW-Authenticate": "Bearer"}, +) + +_UNAUTHORIZED_USER = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Geçersiz oturum.", + headers={"WWW-Authenticate": "Bearer"}, +) + + +class _JwksUnavailable(RuntimeError): + """JWKS belgesi çekilemedi. + + Bu bir yetki sorunu DEĞİLDİR: token geçerli olabilir ama doğrulanamıyordur. + Çağıran katman bunu 503'e çevirir, 401'e değil. + """ + + +class _JwksCache: + """Supabase'in açık anahtarlarını süreç belleğinde tutar. + + Anahtarlar nadiren değişir. Her istekte belgeyi çekmek her doğrulamaya bir + ağ turu eklerdi; onun yerine belge yalnızca TANINMAYAN bir `kid` görüldüğünde + yenilenir. Böylece anahtar döndürüldüğünde (rotation) önbellek, ilk isteğin + gecikmesi pahasına kendiliğinden toparlanır. + """ + + def __init__(self) -> None: + # kid → açık anahtar. + self._keys: dict[str, PyJWK] = {} + # Aynı anda gelen isteklerin aynı yenilemeyi tekrar tekrar yapmaması için. + self._lock = asyncio.Lock() + # Son yenileme anı. `monotonic` kullanılır: sistem saati geriye alınsa + # bile iki olay arasındaki süre doğru ölçülür. + self._last_refresh: float | None = None + + async def get(self, kid: str) -> PyJWK | None: + """`kid`e karşılık gelen açık anahtarı döndürür; bulunamazsa None. + + Önbellekte yoksa belge bir kez yenilenir — ama yalnızca son yenilemenin + üzerinden `JWKS_MIN_REFRESH_SECONDS` geçtiyse. Aksi halde None döner ve + çağıran token'ı reddeder. + """ + key = self._keys.get(kid) + if key is not None: + return key + + async with self._lock: + # Kilit beklenirken başka bir istek yenilemiş olabilir; o zaman + # ikinci bir çekime gerek yok. + key = self._keys.get(kid) + if key is not None: + return key + + if not self._may_refresh(): + return None + + await self._refresh() + return self._keys.get(kid) + + def _may_refresh(self) -> bool: + """Yeni bir çekim için yeterli süre geçti mi?""" + if self._last_refresh is None: + return True + + return (time.monotonic() - self._last_refresh) >= JWKS_MIN_REFRESH_SECONDS + + async def _refresh(self) -> None: + """JWKS belgesini çeker ve önbelleği yeni anahtar kümesiyle değiştirir.""" + url = f"{get_project_url()}{JWKS_PATH}" + + # Zaman damgası çekimin SONUCUNDAN önce yazılır: istek başarısız olsa da + # aralık işlemeye başlasın, yoksa Supabase erişilemez olduğunda gelen + # her istek yeni bir deneme başlatırdı. + self._last_refresh = time.monotonic() + + # Kısa ömürlü istemci: bu çekim nadiren olur, kalıcı bir bağlantı + # havuzunun sağlayacağı kazanç yok. + try: + async with httpx.AsyncClient(timeout=JWKS_TIMEOUT_SECONDS) as client: + response = await client.get(url) + response.raise_for_status() + document = response.json() + + key_set = PyJWKSet.from_dict(document) + except (httpx.HTTPError, ValueError, PyJWTError) as error: + # Adres loglanır (sır değil), yanıt gövdesi loglanmaz. + logger.error("JWKS çekilemedi (%s): %r", url, error) + raise _JwksUnavailable(str(error)) from error + + # `kid` taşımayan anahtar eşleştirmede kullanılamaz, atlanır. + self._keys = {key.key_id: key for key in key_set.keys if key.key_id} + logger.info("JWKS yenilendi — %d anahtar", len(self._keys)) + + +# Süreç ömrü boyunca tek örnek. Fly'da birden fazla makine varsa her biri kendi +# önbelleğini tutar; belge zaten herkese açık ve aynıdır. +_jwks = _JwksCache() + + +def _bearer_token(authorization: str | None, on_failure: HTTPException) -> str: + """`Authorization: Bearer ` başlığından değeri ayıklar. + + Başlık yoksa, ön ek tutmuyorsa veya değer boşsa `on_failure` fırlatılır. + """ + if not authorization or not authorization.startswith(BEARER_PREFIX): + raise on_failure + + token = authorization[len(BEARER_PREFIX) :].strip() + if not token: + raise on_failure + + return token + + async def require_device( authorization: Annotated[str | None, Header()] = None, ) -> DeviceIdentity: @@ -47,12 +214,7 @@ async def require_device( Eşleşme yoksa 401. Supabase'e ulaşılamıyorsa 503: bu bir yetki sorunu değildir ve agent'ın anahtarını geçersiz sayıp vazgeçmesi istenmez. """ - if not authorization or not authorization.startswith(BEARER_PREFIX): - raise _UNAUTHORIZED - - key = authorization[len(BEARER_PREFIX) :].strip() - if not key: - raise _UNAUTHORIZED + key = _bearer_token(authorization, _UNAUTHORIZED_DEVICE) key_hash = hash_device_key(key) try: @@ -66,7 +228,7 @@ async def require_device( # Satır sorgusu zaten hash eşitliğiyle yapıldı; karşılaştırma sabit süreli # bir ikinci kapı olarak burada tekrarlanır. if row is None or not hashes_match(row["key_hash"], key_hash): - raise _UNAUTHORIZED + raise _UNAUTHORIZED_DEVICE return DeviceIdentity( id=row["id"], @@ -76,5 +238,64 @@ async def require_device( ) -# Endpoint imzalarında tekrar etmemek için hazır bağımlılık tipi. +async def require_user( + authorization: Annotated[str | None, Header()] = None, +) -> UserIdentity: + """`Authorization: Bearer ` başlığını doğrular. + + Sıra: token'ın başlığından `kid` okunur → o `kid`in açık anahtarı bulunur → + imza, süre (`exp`), hedef (`aud`) ve kaynak (`iss`) doğrulanır → `role` + alanının `authenticated` olduğu görülür → `sub` account_id olarak döner. + + `kid` başlıktan İMZA DOĞRULANMADAN okunur; bu güvenli çünkü o değer yalnızca + "hangi anahtarla bakacağız" sorusunu cevaplar. Yanlış veya uydurma bir `kid`, + doğrulanacak anahtarı bulamadığı için token'ı geçerli kılmaz. + """ + token = _bearer_token(authorization, _UNAUTHORIZED_USER) + + try: + kid = jwt.get_unverified_header(token).get("kid") + except PyJWTError: + raise _UNAUTHORIZED_USER from None + + if not kid: + raise _UNAUTHORIZED_USER + + try: + key = await _jwks.get(kid) + except _JwksUnavailable as error: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Doğrulama şu an yapılamıyor.", + ) from error + + if key is None: + raise _UNAUTHORIZED_USER + + try: + claims = jwt.decode( + token, + key, + algorithms=ALLOWED_ALGORITHMS, + audience=EXPECTED_AUDIENCE, + issuer=f"{get_project_url()}{AUTH_PATH}", + options={"require": REQUIRED_CLAIMS}, + ) + except PyJWTError as error: + # Yalnızca hatanın TÜRÜ loglanır — token'ın kendisi bir sırdır ve + # `fly logs` çıktısına düşmemelidir. + logger.info("JWT reddedildi: %s", type(error).__name__) + raise _UNAUTHORIZED_USER from None + + # İmza doğru olsa bile rolü `authenticated` olmayan bir token (örneğin bir + # servis anahtarı) bir son kullanıcıyı temsil etmez. + if claims.get("role") != EXPECTED_ROLE: + logger.info("JWT reddedildi: beklenmeyen role") + raise _UNAUTHORIZED_USER + + return UserIdentity(account_id=str(claims["sub"])) + + +# Endpoint imzalarında tekrar etmemek için hazır bağımlılık tipleri. AuthenticatedDevice = Annotated[DeviceIdentity, Depends(require_device)] +AuthenticatedUser = Annotated[UserIdentity, Depends(require_user)] diff --git a/collector/endpoints_device.py b/collector/endpoints_device.py new file mode 100644 index 0000000..d6a4dd9 --- /dev/null +++ b/collector/endpoints_device.py @@ -0,0 +1,95 @@ +""" +Cihaz kaydı ucu: POST /devices. + +Bu uç agent'ın değil DASHBOARD'un çağırdığı tek yazma ucudur; bu yüzden cihaz +anahtarıyla değil kullanıcı JWT'siyle korunur. Cihazın anahtarı burada üretilir: +veritabanına yalnızca SHA-256 özeti (`devices.key_hash`) yazılır, düz hali tek +seferlik yanıtta döner ve hiçbir yerde saklanmaz. + +`device_id` de burada doğar — sunucu üretir (`gen_random_uuid()`), istemci +seçemez. +""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, HTTPException, status +from pydantic import BaseModel, ConfigDict, StringConstraints + +from auth import AuthenticatedUser +from hashing import generate_device_key, hash_device_key +from supabase_client import UNIQUE_VIOLATION, SupabaseError, get_client + +router = APIRouter() + +# Cihaz adı için üst sınır. Sütun tipi `text`, yani veritabanı tarafında sınır +# yok; kontrol burada, satır oluşturulmadan önce yapılır. +MAX_DEVICE_NAME_LENGTH = 64 + +# Baştaki/sondaki boşluklar kırpılır, sonra uzunluk kontrol edilir. Kırpma önce +# yapıldığı için yalnızca boşluktan oluşan bir ad boş sayılır ve reddedilir. +DeviceName = Annotated[ + str, + StringConstraints( + strip_whitespace=True, + min_length=1, + max_length=MAX_DEVICE_NAME_LENGTH, + ), +] + + +class DeviceCreateIn(BaseModel): + """POST /devices gövdesi. + + `extra="forbid"`: sözleşmede olmayan alan sessizce yutulmaz, 422 döner. + Burada bu ayarın ayrı bir önemi var — `account_id` veya `key_hash` gibi bir + alanı gövdeye yazmayı deneyen istek, hiç işlenmeden reddedilir. + """ + + model_config = ConfigDict(extra="forbid") + + device_name: DeviceName + + +@router.post("/devices", status_code=status.HTTP_201_CREATED) +async def post_devices(payload: DeviceCreateIn, user: AuthenticatedUser) -> dict: + """Yeni cihaz oluşturur ve anahtarını BİR KEZ döndürür. + + Satırın `account_id`'si gövdeden değil doğrulanmış token'dan alınır; yani + kullanıcı cihazı başka bir hesabın altına açamaz. + + Aynı hesapta aynı adı taşıyan bir cihaz varsa `devices (account_id, + device_name)` unique indeksi devreye girer ve 409 döner. Bu, kullanıcının + düzeltebileceği kalıcı bir durumdur — tekrar denemesi anlamsız olduğu için + 503 değil 409 verilir. + """ + device_key = generate_device_key() + + row = { + "account_id": user.account_id, + "device_name": payload.device_name, + "key_hash": hash_device_key(device_key), + } + + try: + created = await get_client().insert_device(row) + except SupabaseError as error: + if error.code == UNIQUE_VIOLATION: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Bu hesapta aynı adı taşıyan bir cihaz zaten var.", + ) from error + + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Cihaz şu an oluşturulamıyor.", + ) from error + + # `device_key` yalnızca bu yanıtta görünür. Kaybedilirse geri getirilemez; + # veritabanında yalnızca özeti var. + return { + "device_id": created["id"], + "device_name": payload.device_name, + "device_key": device_key, + } diff --git a/collector/hashing.py b/collector/hashing.py index 33a2b7c..e948ae4 100644 --- a/collector/hashing.py +++ b/collector/hashing.py @@ -1,19 +1,35 @@ """ -Cihaz anahtarı hash'leme ve karşılaştırma. +Cihaz anahtarı üretme, hash'leme ve karşılaştırma. -Anahtarın düz hali hiçbir yerde saklanmaz; `devices.key_hash` yalnızca burada -üretilen hash'i tutar. +Anahtarın düz hali hiçbir yerde saklanmaz: `POST /devices` onu bir kez üretip +yanıtta döndürür, veritabanına yalnızca `devices.key_hash` yazılır. """ from __future__ import annotations import hashlib import hmac +import secrets # Anahtar ön eki. Doğrulamada kullanılmaz — yalnızca kullanıcının elindeki # metnin ne olduğunu tanımasına yarar. KEY_PREFIX = "tbx_live_" +# Anahtarın rastgele bölümünün BAYT uzunluğu. `token_urlsafe` bu baytları +# base64url'e çevirdiği için üretilen metin daha uzundur (32 bayt → 43 karakter). +KEY_ENTROPY_BYTES = 32 + + +def generate_device_key() -> str: + """Yeni bir cihaz anahtarı üretir: `tbx_live_` ön eki + rastgele son ek. + + Rastgelelik `secrets` modülünden gelir; bu modül işletim sisteminin + kriptografik rastgelelik kaynağını kullanır. `random` modülü ise başlangıç + değerinden (seed) türeyen tahmin edilebilir bir dizi üretir ve bir sır + üretmekte kullanılamaz. + """ + return f"{KEY_PREFIX}{secrets.token_urlsafe(KEY_ENTROPY_BYTES)}" + def hash_device_key(key: str) -> str: """Anahtarın UTF-8 baytlarının SHA-256'sını küçük harf hex olarak döndürür. diff --git a/collector/main.py b/collector/main.py index d9596ec..63a0610 100644 --- a/collector/main.py +++ b/collector/main.py @@ -6,10 +6,10 @@ [Agent] --device key/TLS--> [Collector: Fly.io] --service key--> [Supabase] Bağlı router'lar: + endpoints_device.py POST /devices (user JWT) endpoints_ingest.py POST /inventory, POST /ingest, GET /verify (device key) -Sonraki milestone'larda eklenecek: - M5 -> endpoints_device.py POST /devices (user JWT) +Sonraki milestone'da eklenecek: M6 -> endpoints_commands.py GET /commands (device key) """ @@ -21,6 +21,7 @@ from fastapi import FastAPI import supabase_client +from endpoints_device import router as device_router from endpoints_ingest import router as ingest_router from version import COLLECTOR_VERSION @@ -55,6 +56,7 @@ async def lifespan(app: FastAPI): openapi_url=None, ) +app.include_router(device_router) app.include_router(ingest_router) diff --git a/collector/requirements.txt b/collector/requirements.txt index 38bb5e5..d457803 100644 --- a/collector/requirements.txt +++ b/collector/requirements.txt @@ -20,5 +20,15 @@ uvicorn[standard]==0.34.0 # uygulama ömrü boyunca açık kalır (supabase_client.py). httpx==0.28.1 -# M5'te eklenecek: -# pyjwt -> user JWT doğrulama (auth.py) +# Dashboard'un gönderdiği Supabase access token'ını doğrular (auth.py). +# Token'ın imzasını, süresini ve hedef alanlarını kontrol eder; `sub` alanı +# account_id olarak okunur. +pyjwt==2.13.0 + +# pyjwt'nin ES256 (eliptik eğri) imzasını doğrulayabilmesi için gereken kripto +# motoru. Saf Python'da yapılamayan tek adım budur: bu paket kurulu değilse +# pyjwt ES256 token'ı doğrulamayı reddeder. +# +# Derleyici gerektirmez: PyPI'da `cp311-abi3` etiketli hazır wheel yayınlanıyor +# ve Python 3.12'nin (Dockerfile + CI) kullandığı sürüm budur. +cryptography==50.0.0 diff --git a/collector/supabase_client.py b/collector/supabase_client.py index 89f6ddf..6fb09d6 100644 --- a/collector/supabase_client.py +++ b/collector/supabase_client.py @@ -36,6 +36,15 @@ # okumaya gerek yok. PREFER_MINIMAL = "return=minimal" +# Eklenen satırın geri okunması gerektiğinde kullanılır. Cihaz kaydında `id` +# sunucuda üretilir (`gen_random_uuid()`), yani çağıran onu ancak yanıttan +# öğrenebilir. +PREFER_REPRESENTATION = "return=representation" + +# Postgres'in "tekrar eden anahtar" hata kodu (unique kısıt ihlali). Cihaz +# kaydında `devices (account_id, device_name)` unique indeksi bunu üretir. +UNIQUE_VIOLATION = "23505" + # Collector'ın `devices` satırında yazmasına izin verilen sütunlar. # # Bu liste bir YETKİ sınırıdır; `endpoints_ingest.InventoryIn` ise agent'ın ne @@ -78,7 +87,17 @@ class SupabaseError(RuntimeError): - """Supabase'e yazma/okuma başarısız oldu.""" + """Supabase'e yazma/okuma başarısız oldu. + + `code`, PostgREST yanıtındaki Postgres hata kodudur (`23505` = tekrar eden + anahtar gibi). Ağ hatalarında yanıt hiç oluşmadığı için `"?"` kalır. + Çağıran katman buna bakarak kalıcı bir çakışmayı (409) geçici bir + arızadan (503) ayırır. + """ + + def __init__(self, message: str, code: str = "?") -> None: + super().__init__(message) + self.code = code class SupabaseClient: @@ -140,6 +159,31 @@ async def update_device(self, device_id: str, fields: dict[str, Any]) -> None: headers={"Prefer": PREFER_MINIMAL}, ) + async def insert_device(self, row: dict[str, Any]) -> dict[str, Any]: + """Yeni cihaz satırı ekler ve oluşan satırı (`id` ile birlikte) döndürür. + + `insert_rows` bu iş için kullanılamaz: o metot `ignore-duplicates` + başlığıyla çalışır ve çakışan satırı sessizce atlar. Cihaz kaydında + çakışma (aynı hesapta aynı ad) sessizce geçilecek bir durum değil, + çağırana bildirilmesi gereken bir hatadır — bu yüzden istek o başlık + olmadan gönderilir ve `SupabaseError.code` üzerinden ayırt edilir. + """ + response = await self._request( + "POST", + "/devices", + json=[row], + params={"select": "id"}, + headers={"Prefer": PREFER_REPRESENTATION}, + ) + + rows = response.json() + if not rows: + # PostgREST temsil istendiğinde satırı döndürür; boş gövde + # beklenmedik bir durumdur ve sessizce geçilmemelidir. + raise SupabaseError("POST /devices: oluşturulan satır okunamadı") + + return rows[0] + async def insert_rows(self, table: str, rows: list[dict[str, Any]]) -> None: """Satırları ekler; `id` çakışanları sessizce atlar.""" if not rows: @@ -163,14 +207,15 @@ async def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Respons raise SupabaseError(f"{method} {path}: {error}") from error if response.is_error: + code = _error_code(response) logger.error( "Supabase %s %s → %s (kod: %s)", method, path, response.status_code, - _error_code(response), + code, ) - raise SupabaseError(f"{method} {path}: {response.status_code}") + raise SupabaseError(f"{method} {path}: {response.status_code}", code=code) return response @@ -198,6 +243,11 @@ def _error_code(response: httpx.Response) -> str: _client: SupabaseClient | None = None +# Normalize edilmiş proje adresi. PostgREST yolu `SupabaseClient` içinde +# gömülüdür; Supabase'in DİĞER yolları (auth.py'nin çektiği JWKS belgesi) bu +# taban adresten türetilir. +_project_url: str | None = None + def _normalize_url(raw: str) -> str: """Proje adresini sondaki `/` ve `/rest/v1` ekinden arındırır. @@ -218,7 +268,7 @@ def init_client() -> SupabaseClient: Değişkenler eksikse burada hata verilir: süreç ayağa kalkmaz, Fly sağlık kontrolünde çakılır ve deploy bir önceki sürümde kalır. """ - global _client + global _client, _project_url url = _normalize_url(os.environ.get(SUPABASE_URL_ENV, "")) service_key = os.environ.get(SUPABASE_SERVICE_KEY_ENV, "").strip() @@ -233,18 +283,21 @@ def init_client() -> SupabaseClient: # Yalnızca adres ve anahtarın ön eki loglanır — anahtarın kendisi asla. logger.info("Supabase hedefi: %s (anahtar: %s…)", url, service_key[:11]) + _project_url = url _client = SupabaseClient(url, service_key) return _client async def close_client() -> None: """Açık bağlantıları kapatır (uygulama kapanışında).""" - global _client + global _client, _project_url if _client is not None: await _client.aclose() _client = None + _project_url = None + def get_client() -> SupabaseClient: """Kurulmuş istemciyi döndürür — endpoint'ler bunu kullanır.""" @@ -252,3 +305,15 @@ def get_client() -> SupabaseClient: raise RuntimeError("Supabase istemcisi kurulmadı.") return _client + + +def get_project_url() -> str: + """Supabase projesinin taban adresini döndürür (REST yolu eklenmemiş hâli). + + `auth.py` bunu iki yerde kullanır: JWKS belgesinin adresini kurmak ve + token'daki `iss` alanının beklenen değerini hesaplamak. + """ + if _project_url is None: + raise RuntimeError("Supabase istemcisi kurulmadı.") + + return _project_url diff --git a/collector/version.py b/collector/version.py index 9b8087f..b20ec6c 100644 --- a/collector/version.py +++ b/collector/version.py @@ -13,4 +13,4 @@ # Agent'ın bildirdiği agent_version'dan bağımsız, collector'ın kendi sürümü. # Ayakta olan sürüm GET /verify üzerinden doğrulanır — kimliksiz uçlar (/ ve # /health) sürüm döndürmez. -COLLECTOR_VERSION = "0.2.0" +COLLECTOR_VERSION = "0.3.0" diff --git a/tests/test_auth_user.py b/tests/test_auth_user.py new file mode 100644 index 0000000..e81a589 --- /dev/null +++ b/tests/test_auth_user.py @@ -0,0 +1,418 @@ +""" +collector/auth.py — `require_user`: dashboard'un Supabase access token'ı. + +Bu uç (`POST /devices`) cihaz oluşturur ve anahtar üretir. Doğrulama gevşerse +saldırgan başkasının hesabına cihaz açabilir; `account_id` gövdeden değil +buradan geldiği için tüm izolasyon bu fonksiyona dayanır. + +Testler AĞA ÇIKMAZ ama gerçek kripto kullanır: yerel bir ES256 anahtar çifti +üretilir, açık anahtar 127.0.0.1'de çalışan küçük bir sunucudan JWKS olarak +servis edilir. Böylece imza doğrulaması, JWKS çekimi ve önbellek gerçekten +çalışır — sahte bir doğrulayıcı değil. +""" + +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import hmac +import json +import threading +import time +from http.server import BaseHTTPRequestHandler, HTTPServer + +import jwt +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec +from fastapi import HTTPException +from jwt.algorithms import ECAlgorithm + +import auth + +# Supabase'in token'a koyduğu sabitler. +ACCOUNT_ID = "11111111-1111-1111-1111-111111111111" +VALID_KID = "gecerli-anahtar" + +# JWKS'te KARŞILIĞI OLMAYAN bir kid — önbelleğin bilmediği anahtar. +UNKNOWN_KID = "bilinmeyen-anahtar" + +# Kapalı olduğu garanti adres: JWKS çekilemediğinde ne olduğunu sınamak için. +# Ulaşılamayan bir alan adı seçilseydi test timeout boyunca beklerdi. +UNREACHABLE_URL = "http://127.0.0.1:1" + + +# --- Anahtarlar ------------------------------------------------------------ +# İki çift üretilir: biri gerçek imzalayan, diğeri "doğru kid'i taşıyan ama +# yanlış anahtarla imzalanmış" token'ı kurmak için. + +SIGNING_KEY = ec.generate_private_key(ec.SECP256R1()) +ROGUE_KEY = ec.generate_private_key(ec.SECP256R1()) + + +def _jwks_document() -> dict: + """Sunucunun servis edeceği JWKS belgesi — yalnızca GEÇERLİ anahtarı içerir.""" + key = json.loads(ECAlgorithm.to_jwk(SIGNING_KEY.public_key())) + key.update({"kid": VALID_KID, "use": "sig", "alg": "ES256"}) + return {"keys": [key]} + + +# --- Sahte Supabase JWKS sunucusu ------------------------------------------ + + +class _JwksServer: + """JWKS belgesini servis eden ve KAÇ KEZ istendiğini sayan mini sunucu.""" + + def __init__(self) -> None: + self.request_count = 0 + document = json.dumps(_jwks_document()).encode() + server_self = self + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + server_self.request_count += 1 + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(document))) + self.end_headers() + self.wfile.write(document) + + def log_message(self, *args): + """Test çıktısını kirletmesin.""" + + self._httpd = HTTPServer(("127.0.0.1", 0), Handler) + self.base_url = f"http://127.0.0.1:{self._httpd.server_address[1]}" + threading.Thread(target=self._httpd.serve_forever, daemon=True).start() + + def shutdown(self) -> None: + self._httpd.shutdown() + self._httpd.server_close() + + +@pytest.fixture(scope="module") +def jwks_server(): + server = _JwksServer() + yield server + server.shutdown() + + +@pytest.fixture +def project_url(jwks_server, monkeypatch): + """Collector'a Supabase adresi olarak sahte sunucuyu gösterir. + + Önbellek de sıfırlanır: modül düzeyinde tek bir örnek olduğu için bir + testin çektiği anahtar diğerine sızardı ve çekim sayısını sayan testler + birbirine bağımlı hale gelirdi. + """ + monkeypatch.setattr(auth, "get_project_url", lambda: jwks_server.base_url) + monkeypatch.setattr(auth, "_jwks", auth._JwksCache()) + jwks_server.request_count = 0 + return jwks_server.base_url + + +def make_token(project: str, *, key=None, kid: str = VALID_KID, algorithm: str = "ES256", **overrides) -> str: + """Geçerli bir Supabase token'ı üretir; `overrides` ile tek alan bozulur. + + `None` verilen alan claim'lerden TAMAMEN çıkarılır — "eksik alan" + senaryolarını kurmak için. + """ + claims = { + "sub": ACCOUNT_ID, + "aud": auth.EXPECTED_AUDIENCE, + "iss": f"{project}{auth.AUTH_PATH}", + "role": auth.EXPECTED_ROLE, + "exp": int(time.time()) + 3600, + } + claims.update(overrides) + claims = {name: value for name, value in claims.items() if value is not None} + + headers = {"kid": kid} if kid is not None else {} + return jwt.encode(claims, key or SIGNING_KEY, algorithm=algorithm, headers=headers) + + +def authorize(token: str) -> auth.UserIdentity: + """`require_user`'ı doğrudan çağırır (uç noktadan geçmeden).""" + return asyncio.run(auth.require_user(authorization=f"Bearer {token}")) + + +def status_of(token_or_header: str, *, raw: bool = False) -> int: + """Reddedilen bir isteğin HTTP durum kodunu döndürür.""" + header = token_or_header if raw else f"Bearer {token_or_header}" + with pytest.raises(HTTPException) as info: + asyncio.run(auth.require_user(authorization=header)) + return info.value.status_code + + + +# --- Elle kurulmuş saldırı token'ları -------------------------------------- +# PyJWT bu iki token'ı ÜRETMEYİ reddeder (imzasız token yazmaz, açık anahtarı +# HMAC sırrı olarak kabul etmez). Saldırganın böyle bir kısıtı yok; o yüzden +# token'lar burada baytlarından kuruluyor. + +PUBLIC_KEY_PEM = SIGNING_KEY.public_key().public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo +) + + +def _segment(data: dict) -> bytes: + """JWT parçası: JSON → base64url (dolgu karakterleri atılmış).""" + return base64.urlsafe_b64encode(json.dumps(data).encode()).rstrip(b"=") + + +def _attack_claims(project: str) -> dict: + """Geçerli bir token'ın taşıyacağı alanların aynısı — fark yalnızca imzada.""" + return { + "sub": ACCOUNT_ID, + "aud": auth.EXPECTED_AUDIENCE, + "iss": f"{project}{auth.AUTH_PATH}", + "role": auth.EXPECTED_ROLE, + "exp": int(time.time()) + 3600, + } + + +def craft_unsigned_token(project: str) -> str: + """`alg: none` başlıklı, imza bölümü BOŞ token.""" + header = _segment({"alg": "none", "typ": "JWT", "kid": VALID_KID}) + return (header + b"." + _segment(_attack_claims(project)) + b".").decode() + + +def craft_hmac_token(project: str) -> str: + """Açık anahtarı HMAC sırrı gibi kullanarak imzalanmış token.""" + header = _segment({"alg": "HS256", "typ": "JWT", "kid": VALID_KID}) + signing_input = header + b"." + _segment(_attack_claims(project)) + signature = hmac.new(PUBLIC_KEY_PEM, signing_input, hashlib.sha256).digest() + return (signing_input + b"." + base64.urlsafe_b64encode(signature).rstrip(b"=")).decode() + + +# --- Kabul ----------------------------------------------------------------- + + +def test_valid_token_is_accepted(project_url): + """Geçerli token geçmeli — yoksa hiçbir kullanıcı cihaz ekleyemez.""" + identity = authorize(make_token(project_url)) + assert identity.account_id == ACCOUNT_ID + + +def test_account_id_comes_from_the_sub_claim(project_url): + """Hesap kimliği token'ın `sub` alanıdır; şemada `accounts.id` ile aynıdır. + + Başka bir alandan (ya da sabit bir değerden) okunsaydı tüm cihazlar tek + hesaba düşerdi ve kullanıcı izolasyonu diye bir şey kalmazdı. + """ + other = "22222222-2222-2222-2222-222222222222" + assert authorize(make_token(project_url, sub=other)).account_id == other + + +# --- Başlık biçimi --------------------------------------------------------- + + +@pytest.mark.parametrize( + "header", + [ + None, + "", + "Basic abc", + "bearer-token", + "Bearer", + "Bearer ", + "Bearer ", + ], + ids=["yok", "bos", "basic", "onek-yok", "deger-yok", "bosluk", "sadece-bosluk"], +) +def test_malformed_authorization_header_is_rejected(project_url, header): + """Beklenen biçimde olmayan başlık hiç ayrıştırılmadan reddedilir.""" + with pytest.raises(HTTPException) as info: + asyncio.run(auth.require_user(authorization=header)) + assert info.value.status_code == 401 + + +def test_garbage_token_is_rejected(project_url): + """JWT olmayan bir metin çözümlenemeden reddedilmeli (çökmemeli).""" + assert status_of("bu-bir-jwt-degil") == 401 + + +# --- İmza ------------------------------------------------------------------ + + +def test_token_signed_by_another_key_is_rejected(project_url): + """Bu dosyadaki en kritik test. + + Token'ın kid'i doğru, biçimi kusursuz, tüm alanları yerinde — yalnızca + imza başka bir anahtarla atılmış. İmza doğrulaması atlanırsa bu token + kabul edilir ve saldırgan İSTEDİĞİ `sub` değerini yazarak herhangi bir + hesabın adına cihaz açabilir. Dışarıdan hiçbir şey bozulmuş görünmez. + """ + assert status_of(make_token(project_url, key=ROGUE_KEY)) == 401 + + +def test_configuration_allows_only_es256(project_url): + """Algoritma izin listesi tek elemanlı kalmalı. + + Bu doğrudan bir sözleşme iddiası, davranış testi değil — ve bilerek öyle. + Aşağıdaki iki saldırı testi, liste genişletilse BİLE reddedilir: JWKS'ten + gelen anahtar `alg: ES256` etiketi taşır ve PyJWT, token'ın algoritması + anahtarınkiyle uyuşmazsa doğrulamayı reddeder. Yani savunma iki katmanlı + ve listeyi genişletmek tek başına açığa yol açmıyor. + + Bu da o testleri liste değişikliğine KÖR bırakıyor. Listenin kendisini + burada sabitlemek, bekçisiz kalacak tek noktayı kapatır. + """ + assert auth.ALLOWED_ALGORITHMS == ["ES256"] + + +def test_unsigned_token_is_rejected(project_url): + """`alg: none` — imzasız token. + + JWT kütüphanelerinin klasik açığı: token "imzam yok" der ve doğrulayıcı + bunu kabul eder. Token elle kuruluyor; PyJWT böyle bir token'ı üretmeyi + reddettiği için `jwt.encode` ile hazırlanamaz. + """ + assert status_of(craft_unsigned_token(project_url)) == 401 + + +def test_forged_hmac_token_is_rejected(project_url): + """Algoritma karışıklığı (algorithm confusion) saldırısı. + + Doğrulama anahtarı HERKESE AÇIKTIR. Saldırgan onu bir HMAC sırrı gibi + kullanıp kendi token'ını imzalar; doğrulayıcı "aynı anahtar" gördüğü için + imzayı geçerli sayabilir. Sahte token gerçek açık anahtarla imzalanıyor, + yani saldırının birebir kendisi. + """ + assert status_of(craft_hmac_token(project_url)) == 401 + + +# --- Alanlar --------------------------------------------------------------- + + +def test_expired_token_is_rejected(project_url): + """Süresi dolmuş oturum kabul edilmemeli.""" + assert status_of(make_token(project_url, exp=int(time.time()) - 1)) == 401 + + +def test_token_without_expiry_is_rejected(project_url): + """`exp` yoksa token SÜRESİZ geçerli olurdu — sızan bir token hiç ölmezdi.""" + assert status_of(make_token(project_url, exp=None)) == 401 + + +def test_token_without_subject_is_rejected(project_url): + """`sub` yoksa `account_id` yoktur; satır sahipsiz yazılırdı.""" + assert status_of(make_token(project_url, sub=None)) == 401 + + +def test_token_for_another_audience_is_rejected(project_url): + """Başka bir uygulama için üretilmiş token bu servise girmemeli.""" + assert status_of(make_token(project_url, aud="baska-uygulama")) == 401 + + +def test_token_from_another_issuer_is_rejected(project_url): + """Başka bir Supabase projesinin token'ı kabul edilmemeli. + + İmza kendi projesinde geçerlidir; `iss` kontrolü olmasaydı yalnızca aynı + kid'i taşıması yeterdi. + """ + assert status_of(make_token(project_url, iss="https://baska-proje.supabase.co/auth/v1")) == 401 + + +@pytest.mark.parametrize("role", ["anon", "service_role", "", None], ids=["anon", "service", "bos", "yok"]) +def test_token_without_authenticated_role_is_rejected(project_url, role): + """İmzası doğru olsa da son kullanıcıyı temsil etmeyen token reddedilir. + + `service_role` özellikle önemli: o token RLS'i tamamen bypass eder ve bir + son kullanıcıya ait değildir. + """ + assert status_of(make_token(project_url, role=role)) == 401 + + +# --- kid ve JWKS ----------------------------------------------------------- + + +def test_token_without_kid_is_rejected(project_url): + """`kid` yoksa hangi anahtarla doğrulanacağı bilinemez.""" + assert status_of(make_token(project_url, kid=None)) == 401 + + +def test_unknown_kid_is_rejected(project_url): + """JWKS'te karşılığı olmayan kid — imza doğrulanamaz.""" + assert status_of(make_token(project_url, kid=UNKNOWN_KID)) == 401 + + +def test_jwks_is_fetched_once_and_cached(project_url, jwks_server): + """Açık anahtarlar nadiren değişir; her doğrulama bir ağ turu ödememeli.""" + for _ in range(5): + authorize(make_token(project_url)) + + assert jwks_server.request_count == 1 + + +def test_unknown_kid_flood_does_not_amplify_into_supabase(project_url, jwks_server): + """Servis dışı bırakma (DoS) yükseltmesine karşı fren. + + Tanınmayan her kid anında bir JWKS çekimi tetikleseydi, saldırgan uydurma + kid'lerle collector'ı Supabase'e istek üretmeye zorlardı: kendi + altyapımızı kendi sağlayıcımıza saldırtan bir kaldıraç. Aralık sınırı + (JWKS_MIN_REFRESH_SECONDS) o zinciri keser. + """ + for index in range(20): + assert status_of(make_token(project_url, kid=f"uydurma-{index}")) == 401 + + assert jwks_server.request_count <= 1 + + +def test_unavailable_jwks_gives_503_not_401(monkeypatch): + """Doğrulanamamak ile reddedilmek aynı şey değildir. + + 401 dönseydi dashboard kullanıcıya "oturumunuz geçersiz" derdi ve kullanıcı + aslında sağlam olan oturumunu kapatıp tekrar giriş yapmaya çalışırdı. 503 + "şu an bakamıyorum, tekrar dene" demektir. + """ + monkeypatch.setattr(auth, "get_project_url", lambda: UNREACHABLE_URL) + monkeypatch.setattr(auth, "_jwks", auth._JwksCache()) + + token = make_token(UNREACHABLE_URL) + with pytest.raises(HTTPException) as info: + asyncio.run(auth.require_user(authorization=f"Bearer {token}")) + + assert info.value.status_code == 503 + + +# --- Sızıntı --------------------------------------------------------------- + + +def test_rejection_does_not_leak_the_token(project_url, caplog): + """Token bir SIRDIR: `fly logs` çıktısına düşerse oturum çalınabilir. + + Reddedilen istekleri loglamak teşhis için gerekli; loglanan şeyin hatanın + TÜRÜ olması, token'ın kendisi olmaması gerekiyor. + """ + token = make_token(project_url, key=ROGUE_KEY) + + with caplog.at_level("DEBUG", logger="tracebox.auth"): + assert status_of(token) == 401 + + assert token not in caplog.text + + +def test_rejection_message_is_the_same_for_every_failure(project_url): + """Hata mesajı "neden" olduğunu söylememeli. + + Farklı sebepler farklı mesaj verseydi saldırgan yanıtlara bakarak + ilerleyebilirdi: "kid doğru ama imza yanlış" ile "kid yok" arasındaki fark + bir ipucudur. + """ + reasons = [ + make_token(project_url, key=ROGUE_KEY), + make_token(project_url, exp=int(time.time()) - 1), + make_token(project_url, aud="baska"), + make_token(project_url, role="anon"), + make_token(project_url, kid=UNKNOWN_KID), + "bu-bir-jwt-degil", + ] + + details = set() + for token in reasons: + with pytest.raises(HTTPException) as info: + asyncio.run(auth.require_user(authorization=f"Bearer {token}")) + details.add(info.value.detail) + + assert len(details) == 1 diff --git a/tests/test_config_permissions.py b/tests/test_config_permissions.py new file mode 100644 index 0000000..51533ac --- /dev/null +++ b/tests/test_config_permissions.py @@ -0,0 +1,200 @@ +""" +agent/core/config.py — config.toml'un izin denetimi. + +config.toml, cihaz anahtarının DÜZ halini tutan tek dosyadır; sunucuda yalnızca +SHA-256 özeti vardır. Dosyayı okuyabilen, cihazı taklit edebilir. + +Denetimin iki yüzü var ve ikisi de kolayca yanlış tarafa kayar: +gevşek izin fark edilmezse sır sessizce açıkta kalır; fark edilip agent +durdurulursa makine tamamen gözsüz kalır. Buradaki testler her iki kaymayı da +tutar. +""" + +from __future__ import annotations + +import os +import stat + +import pytest + +from agent.core.config import ConfigLoader, check_permissions + +CONFIG_BODY = """ +collector_url = "https://collector.example" +device_key = "tbx_live_cok_gizli_anahtar" +""" + +DEVICE_KEY = "tbx_live_cok_gizli_anahtar" + +SECURE_MODE = 0o600 + + +@pytest.fixture +def config_file(tmp_path): + """Geçerli içerikli, 600 izinli bir config.toml.""" + path = tmp_path / "config.toml" + path.write_text(CONFIG_BODY) + path.chmod(SECURE_MODE) + return path + + +class Warnings(list): + """`warn` yerine geçer; basılan uyarıları toplar.""" + + def __call__(self, message: str) -> None: + self.append(message) + + +def mode_of(path) -> int: + return path.stat().st_mode + + +# --- check_permissions ----------------------------------------------------- + + +def test_owner_only_permissions_are_accepted(config_file): + """600 doğru ayardır; uyarı çıkmamalı, yoksa uyarı anlamını yitirir.""" + warnings = Warnings() + assert check_permissions(config_file, mode_of(config_file), warn=warnings) is True + assert warnings == [] + + +@pytest.mark.parametrize( + "mode", + [0o640, 0o644, 0o604, 0o660, 0o606, 0o666, 0o610, 0o601, 0o777], + ids=lambda m: oct(m), +) +def test_any_access_beyond_the_owner_is_flagged(config_file, mode): + """Bu dosyadaki en kritik test. + + Denetim yalnızca "diğerleri" (other) bitlerine baksaydı 640 temiz görünürdü + — ama makinedeki bir GRUP üyesi anahtarı okuyabilirdi. Yalnızca okuma + bitine baksaydı 620 temiz görünürdü; yazma da bir saldırı yoludur (anahtarı + saldırganın kendi anahtarıyla değiştirmek). + + Sahibi dışında herhangi biri için herhangi bir bit açıksa dosya güvensizdir. + """ + config_file.chmod(mode) + warnings = Warnings() + + assert check_permissions(config_file, mode_of(config_file), warn=warnings) is False + assert len(warnings) == 1 + + +def test_warning_tells_the_user_how_to_fix_it(config_file): + """Uyarı hem hangi dosyayı hem de düzeltme komutunu içermeli. + + "izinler fazla açık" tek başına, kullanıcının ne yapacağını bilmediği bir + uyarıdır ve göz ardı edilir. + """ + config_file.chmod(0o644) + warnings = Warnings() + check_permissions(config_file, mode_of(config_file), warn=warnings) + + assert str(config_file) in warnings[0] + assert "chmod 600" in warnings[0] + + +def test_warning_does_not_contain_the_key(config_file): + """Uyarı journald'a düşer ve journald'ı bu makinedeki başkaları okuyabilir. + + Sırrı, sırrın açıkta olduğunu söyleyen mesajın içine koymak açığı + büyütürdü. + """ + config_file.chmod(0o644) + warnings = Warnings() + check_permissions(config_file, mode_of(config_file), warn=warnings) + + assert DEVICE_KEY not in warnings[0] + + +# --- Yükleyicinin davranışı ------------------------------------------------ + + +def test_loose_permissions_do_not_stop_the_agent(config_file): + """Denetim uyarır, ÖLDÜRMEZ. + + ConfigError yükselseydi agent açılamaz, systemd 5 denemede pes eder ve + makine tamamen izlenemez hale gelirdi — bir izin bitinin yaratacağından çok + daha büyük bir zarar. + """ + config_file.chmod(0o644) + warnings = Warnings() + + config = ConfigLoader(config_file, warn=warnings).load() + + assert config.device_key == DEVICE_KEY + assert any("chmod 600" in message for message in warnings) + + +def test_secure_file_is_loaded_without_any_warning(config_file): + """Doğru kurulmuş bir sistem sessiz olmalı.""" + warnings = Warnings() + ConfigLoader(config_file, warn=warnings).load() + assert warnings == [] + + +def test_permission_warning_is_not_repeated_every_tick(config_file): + """Döngü saniyede bir load() çağırır. + + Uyarı her çağrıda basılsaydı journald dolar, gerçek olaylar arasında + kaybolurdu. Dosya değişmediyse denetim de tekrarlanmaz. + """ + config_file.chmod(0o644) + warnings = Warnings() + loader = ConfigLoader(config_file, warn=warnings) + + for _ in range(10): + loader.load() + + assert len(warnings) == 1 + + +def test_permissions_loosened_after_startup_are_noticed(config_file): + """İkinci en kritik test — M5'te kapatılan boşluk. + + `chmod` dosyanın ne mtime'ını ne boyutunu değiştirir. Önbellek imzası + yalnızca bu ikisine bakarsa, açılışta 600 olan bir dosya sonradan 644 + yapıldığında agent bunu ÖMÜR BOYU fark etmez: sır açığa çıkar ve tek + bekçisi olan uyarı hiç basılmaz. + """ + warnings = Warnings() + loader = ConfigLoader(config_file, warn=warnings) + + loader.load() + assert warnings == [] + + config_file.chmod(0o644) + loader.load() + + assert any("chmod 600" in message for message in warnings) + + +def test_tightening_permissions_clears_the_warning(config_file): + """Kullanıcı düzeltince uyarı susmalı — yoksa düzeltmenin işe yaradığı + anlaşılmaz ve uyarı gürültüye dönüşür.""" + config_file.chmod(0o644) + warnings = Warnings() + loader = ConfigLoader(config_file, warn=warnings) + loader.load() + + config_file.chmod(SECURE_MODE) + loader.load() + before = len(warnings) + loader.load() + + assert len(warnings) == before + + +@pytest.mark.skipif(os.geteuid() == 0, reason="root her dosyayı okur, izin denetimi bypass edilir") +def test_unreadable_file_is_a_startup_error(config_file): + """Hiç okunamayan config, uyarı değil hatadır. + + Agent'ın yanlış/eksik ayarla açılmaması gerekir: adressiz bir agent veriyi + hiçbir yere gönderemez ama "çalışıyor" görünür. + """ + from agent.core.config import ConfigError + + config_file.chmod(0o000) + with pytest.raises(ConfigError): + ConfigLoader(config_file, warn=Warnings()).load() diff --git a/tests/test_endpoints_device.py b/tests/test_endpoints_device.py new file mode 100644 index 0000000..0d15d75 --- /dev/null +++ b/tests/test_endpoints_device.py @@ -0,0 +1,249 @@ +""" +collector/endpoints_device.py — POST /devices. + +Sistemdeki tek anahtar üretme noktası. Buradaki hata iki yönde de ağırdır: +anahtar sızarsa cihaz taklit edilir, `account_id` yanlış alınırsa kullanıcı +başka bir hesabın altına cihaz açar. + +Supabase taklit ediliyor: `get_client` sahte bir istemciyle değiştiriliyor, +böylece testler ne ağa çıkıyor ne de bir servis anahtarına ihtiyaç duyuyor. +Kimlik doğrulaması da FastAPI'nin bağımlılık override'ıyla devre dışı — +JWT'nin kendisi zaten test_auth_user.py'de sınanıyor, burada sınanan uç +noktanın MANTIĞI. +""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +import auth +import endpoints_device +from endpoints_device import MAX_DEVICE_NAME_LENGTH +from hashing import KEY_PREFIX, hash_device_key +from main import app +from supabase_client import UNIQUE_VIOLATION, SupabaseError + +ACCOUNT_ID = "11111111-1111-1111-1111-111111111111" + +# Sunucunun ürettiği kimlik. İstemci bunu seçemez; sahte istemci de gerçek +# Supabase gibi kendi ürettiği değeri döndürür. +GENERATED_DEVICE_ID = "99999999-9999-9999-9999-999999999999" + +# Saldırganın kendi hesabı — gövdeye yazmayı deneyeceği değer. +OTHER_ACCOUNT_ID = "22222222-2222-2222-2222-222222222222" + + +class FakeSupabase: + """`insert_device` çağrılarını kaydeden, ağa çıkmayan sahte istemci.""" + + def __init__(self, error: SupabaseError | None = None) -> None: + self.error = error + self.rows: list[dict] = [] + + async def insert_device(self, row: dict) -> dict: + self.rows.append(row) + if self.error is not None: + raise self.error + return {"id": GENERATED_DEVICE_ID} + + +@pytest.fixture +def fake_supabase(monkeypatch): + """Uç noktayı sahte veritabanına bağlar ve kimlik doğrulamasını sabitler.""" + client = FakeSupabase() + monkeypatch.setattr(endpoints_device, "get_client", lambda: client) + app.dependency_overrides[auth.require_user] = lambda: auth.UserIdentity(account_id=ACCOUNT_ID) + yield client + app.dependency_overrides.clear() + + +@pytest.fixture +def client(fake_supabase): + return TestClient(app) + + +def create(client, **body): + """POST /devices — gövde varsayılanı geçerli bir istektir.""" + return client.post("/devices", json={"device_name": "dizustu", **body}) + + +# --- Mutlu yol ------------------------------------------------------------- + + +def test_device_is_created(client): + """Cihaz oluşturulabilmeli — yoksa hiç kimse sisteme cihaz ekleyemez.""" + response = create(client) + assert response.status_code == 201 + + +def test_response_carries_the_generated_identifiers(client): + """Yanıt üç şeyi taşır: sunucunun ürettiği kimlik, ad ve anahtar.""" + body = create(client).json() + assert body["device_id"] == GENERATED_DEVICE_ID + assert body["device_name"] == "dizustu" + assert body["device_key"].startswith(KEY_PREFIX) + + +def test_device_name_is_trimmed(client, fake_supabase): + """Baştaki/sondaki boşluk kırpılır — hem yanıtta hem satırda.""" + body = create(client, device_name=" dizustu ").json() + assert body["device_name"] == "dizustu" + assert fake_supabase.rows[0]["device_name"] == "dizustu" + + +def test_each_device_gets_a_different_key(client): + """İki cihaz aynı anahtarı alsaydı biri diğerinin verisini yazabilirdi.""" + first = create(client, device_name="bir").json()["device_key"] + second = create(client, device_name="iki").json()["device_key"] + assert first != second + + +# --- Anahtarın saklanması -------------------------------------------------- + + +def test_plain_key_is_never_written_to_the_database(client, fake_supabase): + """Bu dosyadaki en kritik test. + + Düz anahtar satıra yazılırsa hiçbir şey görünürde bozulmaz: uç 201 döner, + cihaz bağlanır, testlerin çoğu yeşil kalır. Ama veritabanına erişen herkes + (yedek dosyası, log, bir SQL enjeksiyonu) tüm cihazların kimliğini ele + geçirir. `key_hash` tek yönlüdür; düz anahtar değildir. + """ + key = create(client).json()["device_key"] + row = fake_supabase.rows[0] + + assert key not in row.values() + assert row["key_hash"] != key + + +def test_stored_hash_matches_the_returned_key(client, fake_supabase): + """Saklanan özet, dönen anahtarın özeti olmalı. + + Aksi halde uç "başarılı" der, kullanıcı anahtarı kurar ve agent + `require_device` aşamasında hiçbir zaman eşleşme bulamaz. + """ + key = create(client).json()["device_key"] + assert fake_supabase.rows[0]["key_hash"] == hash_device_key(key) + + +def test_row_contains_nothing_beyond_the_three_expected_columns(client, fake_supabase): + """Satıra fazladan alan sızmamalı — özellikle anahtarın kendisi.""" + create(client) + assert set(fake_supabase.rows[0]) == {"account_id", "device_name", "key_hash"} + + +# --- Hesap izolasyonu ------------------------------------------------------ + + +def test_account_id_comes_from_the_token(client, fake_supabase): + """Satırın sahibi doğrulanmış kullanıcıdır, gövdenin söylediği kişi değil.""" + create(client) + assert fake_supabase.rows[0]["account_id"] == ACCOUNT_ID + + +def test_account_id_in_the_body_is_refused(client, fake_supabase): + """İkinci en kritik test. + + Gövdedeki `account_id` sessizce yok sayılsaydı test yine geçerdi — ama + davranış "yok sayıldı" mı yoksa "kullanıldı" mı olduğunu dışarıdan ayırt + edemezdik. `extra="forbid"` sayesinde istek hiç işlenmiyor: niyet + gürültülü biçimde reddediliyor. + """ + response = create(client, account_id=OTHER_ACCOUNT_ID) + assert response.status_code == 422 + assert fake_supabase.rows == [] + + +@pytest.mark.parametrize("field", ["key_hash", "device_id", "id", "logging_enabled", "last_seen"]) +def test_server_owned_columns_cannot_be_set_from_the_body(client, fake_supabase, field): + """Sunucunun doldurduğu hiçbir sütun istemciden gelemez.""" + assert create(client, **{field: "saldirgan"}).status_code == 422 + assert fake_supabase.rows == [] + + +# --- Gövde doğrulama ------------------------------------------------------- + + +@pytest.mark.parametrize( + "name", + ["", " ", "\t\n", "x" * (MAX_DEVICE_NAME_LENGTH + 1)], + ids=["bos", "bosluk", "beyaz-bosluk", "cok-uzun"], +) +def test_invalid_device_name_is_rejected(client, fake_supabase, name): + """Geçersiz ad satır oluşturmadan reddedilir.""" + assert create(client, device_name=name).status_code == 422 + assert fake_supabase.rows == [] + + +def test_missing_device_name_is_rejected(client, fake_supabase): + """Zorunlu alan eksikse istek işlenmez.""" + assert client.post("/devices", json={}).status_code == 422 + assert fake_supabase.rows == [] + + +def test_longest_allowed_name_is_accepted(client): + """Sınırın kendisi geçerli olmalı — kontrol bir eksik saymamalı.""" + assert create(client, device_name="x" * MAX_DEVICE_NAME_LENGTH).status_code == 201 + + +# --- Hata yolları ---------------------------------------------------------- + + +def test_duplicate_name_gives_409(monkeypatch, fake_supabase): + """Aynı hesapta aynı ad iki kez olamaz (unique indeks). + + 409 seçilmesi bilinçli: kullanıcının düzeltebileceği KALICI bir durum. + 503 dense istemci tekrar denerdi ve sonuç hiç değişmezdi. + """ + fake_supabase.error = SupabaseError("çakışma", code=UNIQUE_VIOLATION) + response = TestClient(app).post("/devices", json={"device_name": "dizustu"}) + assert response.status_code == 409 + + +def test_database_failure_gives_503_not_409(fake_supabase): + """Geçici arıza kalıcı çakışmadan ayrılmalı — istemci tekrar denemeli.""" + fake_supabase.error = SupabaseError("bağlantı koptu", code="08006") + response = TestClient(app).post("/devices", json={"device_name": "dizustu"}) + assert response.status_code == 503 + + +def test_database_error_details_are_not_leaked(fake_supabase): + """Veritabanının kendi mesajı istemciye geçmemeli. + + PostgREST'in `details` alanı sütun adlarını, indeks adlarını ve bazen satır + değerlerini taşır; bunlar saldırgan için şema haritasıdır. + """ + secret = "devices_account_id_device_name_idx sütununda çakışma: dizustu" + fake_supabase.error = SupabaseError(secret, code=UNIQUE_VIOLATION) + response = TestClient(app).post("/devices", json={"device_name": "dizustu"}) + + assert response.status_code == 409 + assert secret not in response.text + assert "idx" not in response.text + + +def test_no_key_is_returned_when_creation_fails(fake_supabase): + """Başarısız istek anahtar sızdırmamalı.""" + fake_supabase.error = SupabaseError("çakışma", code=UNIQUE_VIOLATION) + response = TestClient(app).post("/devices", json={"device_name": "dizustu"}) + assert KEY_PREFIX not in response.text + + +# --- Kimlik doğrulama ------------------------------------------------------ + + +def test_endpoint_requires_authentication(monkeypatch): + """Override YOK: gerçek bağımlılık çalışır ve token'sız istek reddedilir. + + Uç noktanın kendisi bir anahtar fabrikasıdır; kimliksiz erişilebilseydi + herkes istediği hesaba cihaz açabilirdi. + """ + client = FakeSupabase() + monkeypatch.setattr(endpoints_device, "get_client", lambda: client) + app.dependency_overrides.clear() + + response = TestClient(app).post("/devices", json={"device_name": "dizustu"}) + + assert response.status_code == 401 + assert client.rows == [] diff --git a/tests/test_hashing.py b/tests/test_hashing.py index b42ac1d..cd17772 100644 --- a/tests/test_hashing.py +++ b/tests/test_hashing.py @@ -1,6 +1,8 @@ -"""collector/hashing.py — cihaz anahtarının özetlenmesi ve karşılaştırılması.""" +"""collector/hashing.py — cihaz anahtarının üretilmesi, özetlenmesi ve karşılaştırılması.""" -from hashing import hash_device_key, hashes_match +import random + +from hashing import KEY_PREFIX, generate_device_key, hash_device_key, hashes_match def test_hash_does_not_return_plain_key(): @@ -40,3 +42,65 @@ def test_wrong_key_does_not_match_stored_hash(): """Doğrulamanın uçtan uca anlamı: yanlış anahtar kayıtlı özeti tutturamaz.""" stored = hash_device_key("tbx_live_dogru") assert hashes_match(hash_device_key("tbx_live_yanlis"), stored) is False + + +# --------------------------------------------------------------------------- +# generate_device_key — M5'te eklendi. Cihazın TEK kimlik kanıtı bu değerdir; +# tahmin edilebilir olması, saldırganın başkasının cihazı adına veri +# göndermesi (ve komut alması) demektir. +# --------------------------------------------------------------------------- + + +def test_generated_key_carries_the_prefix(): + """Ön ek, anahtarı bir kayıt içinde gözle tanınır kılar.""" + assert generate_device_key().startswith(KEY_PREFIX) + + +def test_generated_keys_are_unique(): + """Aynı anahtarın iki kez üretilmesi, iki cihazın tek satıra düşmesi olurdu. + + `devices.key_hash` üzerinde unique indeks var; çakışma sessiz değil ama + ikinci cihazın kaydı hiç oluşmazdı. + """ + keys = {generate_device_key() for _ in range(1000)} + assert len(keys) == 1000 + + +def test_generated_key_has_enough_entropy(): + """Rastgele bölüm kaba kuvvetle denenemeyecek kadar uzun olmalı. + + 32 bayt base64url'e çevrildiğinde 43 karakter eder. Sınır 40 tutuldu: + kodlama ayrıntısı değişse de asıl iddia (kısa/tahmin edilebilir bir + anahtar üretilmiyor) korunsun. + """ + suffix = generate_device_key()[len(KEY_PREFIX) :] + assert len(suffix) >= 40 + + +def test_generated_key_does_not_come_from_the_random_module(): + """Bu dosyadaki en kritik testlerden biri. + + `random` başlangıç değerinden (seed) türeyen TEKRARLANABİLİR bir dizi + üretir: aynı seed, aynı sayılar. Anahtar üretimi oraya kayarsa hiçbir şey + görünürde bozulmaz — anahtarlar hâlâ uzun, hâlâ ön ekli, testlerin çoğu + hâlâ yeşil — ama üretilen değerler tahmin edilebilir olur. + + Seed aynı noktaya iki kez sabitlenip iki anahtar isteniyor: kaynak `random` + olsaydı ikisi birebir aynı çıkardı. + """ + random.seed(1234) + first = generate_device_key() + random.seed(1234) + second = generate_device_key() + + assert first != second + + +def test_generated_key_hashes_like_any_other_key(): + """Üretim ile doğrulama aynı zinciri kullanmalı. + + Uçtan uca anlam: `POST /devices` bu özeti yazar, `require_device` aynı + anahtardan aynı özeti hesaplayıp satırı bulur. + """ + key = generate_device_key() + assert hashes_match(hash_device_key(key), hash_device_key(key)) is True diff --git a/tests/test_install_scripts.py b/tests/test_install_scripts.py new file mode 100644 index 0000000..50233c3 --- /dev/null +++ b/tests/test_install_scripts.py @@ -0,0 +1,288 @@ +""" +agent/install.sh · agent/uninstall.sh · agent/tracebox-agent.service — sözleşme testleri. + +Bu üç dosya bir birim testinde ÇALIŞTIRILAMAZ: kullanıcı oluşturur, sistem +dizinlerine yazar, systemd'ye dokunur. Doğru çalıştıkları tek seferlik olarak +atılabilir bir Docker konteynerinde elle doğrulandı. + +Buradaki testler farklı bir soruya bakar: dosyalar SÖZLEŞMELERİNİ hâlâ tutuyor +mu? Bir düzenleme sırasında sessizce düşebilecek satırlar var — `chmod 600`, +`read -s`, `User=tracebox` — ve düştüklerinde kurulum yine "başarılı" der. +Kaybı fark eden başka hiçbir şey yok. +""" + +from __future__ import annotations + +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +AGENT_DIR = Path(__file__).resolve().parent.parent / "agent" + +INSTALL = AGENT_DIR / "install.sh" +UNINSTALL = AGENT_DIR / "uninstall.sh" +UNIT = AGENT_DIR / "tracebox-agent.service" + +SCRIPTS = [INSTALL, UNINSTALL] + +# Yalnızca geliştirme için var olan override'lar. Üretim yolunu ezerler; kurulum +# betiğine ya da unit dosyasına sızarlarsa agent /etc ve /var/lib yerine +# geliştiricinin klasörlerine bakar. +DEVELOPMENT_OVERRIDES = ["TRACEBOX_CONFIG", "TRACEBOX_STATE_DIR"] + + +def read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def code(body: str) -> str: + """Yorum satırlarını atar. + + İçerik iddiaları yorumlarla tatmin edilmemeli: bir bayrağı ANLATAN yorum, + o bayrak koddan silinse bile metinde durur ve testi sahte biçimde geçirir. + """ + return "\n".join(line for line in body.splitlines() if not line.strip().startswith("#")) + + +@pytest.fixture(scope="module") +def install_sh() -> str: + return read(INSTALL) + + +@pytest.fixture(scope="module") +def uninstall_sh() -> str: + return read(UNINSTALL) + + +@pytest.fixture(scope="module") +def unit() -> str: + return read(UNIT) + + +# --- Dosyaların kendisi ---------------------------------------------------- + + +@pytest.mark.parametrize("path", SCRIPTS + [UNIT], ids=lambda p: p.name) +def test_file_exists(path): + """install.sh kurulum akışının TEK giriş noktası; eksikse akış kopar.""" + assert path.is_file() + + +@pytest.mark.parametrize("path", SCRIPTS, ids=lambda p: p.name) +def test_script_is_executable(path): + """Çalıştırma biti repo'da tutulmalı. + + Kaybolursa kullanıcı `./install.sh` deyip "Permission denied" alır ve + kurulum daha ilk adımda durur. + """ + assert path.stat().st_mode & 0o111 + + +@pytest.mark.parametrize("path", SCRIPTS, ids=lambda p: p.name) +@pytest.mark.skipif(shutil.which("bash") is None, reason="bash yok") +def test_script_has_valid_syntax(path): + """`bash -n`: betiği çalıştırmadan ayrıştırır. + + Sözdizimi hatası ancak kullanıcının makinesinde, kurulumun ortasında + görülürdü — yarım kurulmuş bir sistem bırakarak. + """ + result = subprocess.run(["bash", "-n", str(path)], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize("path", SCRIPTS, ids=lambda p: p.name) +def test_script_stops_on_the_first_error(path): + """`set -e` + `-u` + `pipefail` olmadan betik hatadan sonra DEVAM eder. + + Bu, kurulumun en tehlikeli hâlidir: indirme başarısız olur ama kullanıcı + oluşturma, servis kurma ve "✓ tamamlandı" mesajı yine de çalışır. + """ + body = read(path) + assert re.search(r"^set -[Eeuo]*e[Eeuo]*uo pipefail$", body, re.MULTILINE), ( + "set -euo pipefail bulunamadı" + ) + + +# --- Kilitli kural: geliştirme override'ları sızmamalı --------------------- + + +@pytest.mark.parametrize("path", SCRIPTS + [UNIT], ids=lambda p: p.name) +@pytest.mark.parametrize("variable", DEVELOPMENT_OVERRIDES) +def test_development_override_does_not_leak_into_production(path, variable): + """Bu dosyadaki en kritik test. + + TRACEBOX_CONFIG, geliştirme sırasında config'i repo içindeki bir dosyaya + yönlendirmek için var. Kurulum betiğine ya da unit dosyasına girerse üretim + agent'ı /etc/tracebox/config.toml yerine başka bir yolu okur — ve orada bir + dosya varsa hiçbir hata vermeden YANLIŞ ayarla çalışır. + + Sessiz olduğu için tek bekçisi bu testtir. + """ + assert variable not in read(path) + + +# --- Anahtarın gizliliği --------------------------------------------------- + + +def test_config_file_is_locked_down(install_sh): + """Düz anahtarı tutan tek dosya yalnızca sahibine açık olmalı. + + `chmod 600` düşerse dosya umask'e kalır (tipik olarak 644) ve makinedeki + her yerel kullanıcı cihaz anahtarını okuyabilir. + """ + assert "chmod 600" in install_sh + + +def test_key_prompt_does_not_echo(install_sh): + """Anahtar yazılırken ekrana basılmamalı. + + `read -s` olmadan anahtar terminalde görünür, omuz üstünden okunur ve + kaydedilen oturumlara (script, tmux, ekran paylaşımı) düşer. + """ + assert re.search(r"read -r -s\b", install_sh) + + +def test_prompts_read_from_the_terminal_not_stdin(install_sh): + """Kurulum `curl … | sudo bash` ile çalıştırılır. + + O akışta stdin BETİĞİN KENDİSİDİR: `read` stdin'den okusaydı, sorunun + cevabı olarak betiğin bir sonraki satırını yutar ve anahtar hiç sorulmadan + kurulum bozulurdu. Bu yüzden sorular /dev/tty'den okunur. + """ + assert "/dev/tty" in install_sh + assert not re.search(r"^\s*read -r[^<\n]*$", install_sh, re.MULTILINE), ( + "yönlendirmesiz bir read var — stdin'den okuyor" + ) + + +def test_key_is_never_printed_back(install_sh): + """Alınan anahtar hiçbir çıktıda tekrarlanmamalı.""" + for line in install_sh.splitlines(): + if "${DEVICE_KEY}" in line and re.match(r"\s*(echo|printf|say)\b", line.strip()): + pytest.fail(f"anahtar ekrana basılıyor: {line.strip()}") + + +def test_rollback_is_disabled_once_the_key_is_written(install_sh): + """İkinci en kritik test. + + Anahtar YALNIZCA config.toml'da düz durur: sunucuda özeti var, dashboard + onu bir kez gösterip unutur. Sonraki bir adım (systemd, verify) hata verip + geri alma tetiklenirse config silinir ve anahtar KALICI olarak kaybolur — + kullanıcı cihazı silip baştan oluşturmak zorunda kalır. + + Bu yüzden config yazıldıktan sonra geri alma kapatılır. + """ + assert "ROLLBACK_ENABLED=0" in install_sh + + write_index = install_sh.index("chmod 600") + disable_index = install_sh.index("ROLLBACK_ENABLED=0") + assert disable_index > write_index, "geri alma, anahtar yazılmadan önce kapatılıyor" + + +# --- İndirme --------------------------------------------------------------- + + +def test_source_is_downloaded_over_https_only(install_sh): + """Yönlendirme düz HTTP'ye düşerse indirme reddedilmeli. + + Kurulan şey /opt/tracebox'ta root'un kurduğu koddur; araya giren biri + içeriği değiştirebilseydi makineye istediği kodu yazdırırdı. + """ + body = code(install_sh) + assert "--proto '=https'" in body, "curl düz HTTP'ye düşmeye açık" + assert not re.search(r"http://[a-z]", body), "düz HTTP adresi var" + + +# --- systemd unit ---------------------------------------------------------- + + +def test_service_does_not_run_as_root(unit): + """En kritik unit testi. + + `User=` satırı düşerse systemd servisi ROOT olarak çalıştırır — ve hiçbir + şey bozulmaz, agent gayet çalışır. Bir metrik toplayıcının makinedeki her + dosyayı okuma/yazma yetkisi olması, sessiz ve kalıcı bir yetki artışıdır. + """ + assert re.search(r"^User=tracebox$", unit, re.MULTILINE) + assert not re.search(r"^User=root$", unit, re.MULTILINE) + + +def test_service_uses_the_virtualenv_interpreter(unit): + """Sistem python3'ü psutil ve httpx'i görmez. + + ExecStart sistem yorumlayıcısına kayarsa servis her açılışta ImportError + ile ölür ve systemd 5 denemede pes eder. + """ + exec_start = re.search(r"^ExecStart=(.+)$", unit, re.MULTILINE) + assert exec_start, "ExecStart yok" + assert exec_start.group(1).startswith("/opt/tracebox/venv/bin/python") + + +def test_service_can_only_write_to_its_state_directory(unit): + """Agent'ın yazdığı tek yer state.json + spool'dur.""" + assert re.search(r"^ProtectSystem=strict$", unit, re.MULTILINE) + assert re.search(r"^ReadWritePaths=/var/lib/tracebox$", unit, re.MULTILINE) + + +def test_service_cannot_escalate_privileges(unit): + """setuid ile yetki yükseltme yolu kapalı olmalı.""" + assert re.search(r"^NoNewPrivileges=yes$", unit, re.MULTILINE) + + +def test_service_has_a_crash_loop_brake(unit): + """Sürekli çöken bir servis, journald'ı ve CPU'yu sonsuza dek meşgul eder.""" + assert re.search(r"^StartLimitBurst=\d+$", unit, re.MULTILINE) + assert re.search(r"^StartLimitIntervalSec=\d+$", unit, re.MULTILINE) + + +def test_crash_loop_brake_is_in_the_unit_section(unit): + """systemd v230'dan beri bu iki ayar [Unit] bölümüne aittir. + + [Service] altında yazılırlarsa systemd onları YOK SAYAR (yalnızca bir uyarı + basar) ve fren hiç devreye girmez. + """ + unit_section = re.split(r"^\[Service\]$", unit, flags=re.MULTILINE)[0] + assert "StartLimitBurst" in unit_section + assert "StartLimitIntervalSec" in unit_section + + +def test_install_and_unit_agree_on_the_service_name(install_sh, unit): + """Betiğin enable ettiği isim, dosyanın adı olmalı.""" + assert f'SERVICE_NAME="{UNIT.name}"' in install_sh + + +# --- uninstall.sh ---------------------------------------------------------- + + +@pytest.mark.parametrize("directory", ["/opt/tracebox", "/etc/tracebox", "/var/lib/tracebox"]) +def test_uninstall_removes_every_directory_install_creates(uninstall_sh, directory): + """Kaldırma eksik kalırsa anahtar (config.toml) makinede kalır. + + `delete` komutu da bu betiği çağırır: kullanıcı cihazı sildiğinde sırrın + diskte kalmaması gerekir. + """ + assert re.search(rf'="{re.escape(directory)}"', uninstall_sh), ( + f"{directory} silinenler arasında değil" + ) + + +def test_uninstall_stops_the_service_before_deleting_files(uninstall_sh): + """Dosyalar önce silinirse çalışan agent hata döngüsüne girer. + + `disable --now` iki işi birden yapar: servisi durdurur ve açılışta + başlamasını engeller. + """ + stop = re.search(r"systemctl (disable --now|stop)", uninstall_sh) + remove = re.search(r"^\s*rm -rf ", uninstall_sh, re.MULTILINE) + + assert stop, "servisi durduran bir systemctl çağrısı yok" + assert remove, "dizinleri silen bir rm çağrısı yok" + assert stop.start() < remove.start() + + +def test_uninstall_removes_the_service_user(uninstall_sh): + """Geride yetim bir sistem kullanıcısı bırakılmamalı.""" + assert "userdel" in uninstall_sh diff --git a/tests/test_verify.py b/tests/test_verify.py new file mode 100644 index 0000000..2899386 --- /dev/null +++ b/tests/test_verify.py @@ -0,0 +1,212 @@ +""" +agent/core/verify.py — GET /verify bağlantı testi. + +Kurulumun son adımı. Kullanıcının gördüğü tek geri bildirim budur: yanlış +cevap verirse ya çalışmayan bir kuruluma "tamam" der ya da çalışan bir kuruluma +"olmadı" deyip kullanıcıyı config'i bozmaya iter. + +Ağa çıkılmıyor: `httpx.get` sahte bir fonksiyonla değiştiriliyor. +""" + +from __future__ import annotations + +import httpx +import pytest + +from agent.core import verify as verify_module +from agent.core.config import Config +from agent.core.verify import REQUEST_TIMEOUT_SECONDS, verify + +COLLECTOR_URL = "https://collector.example" +DEVICE_KEY = "tbx_live_cok_gizli_anahtar" + + +def make_config(collector_url: str = COLLECTOR_URL) -> Config: + return Config(collector_url=collector_url, device_key=DEVICE_KEY) + + +class Recorder: + """`httpx.get` yerine geçer; çağrıyı kaydeder ve hazır yanıtı döndürür.""" + + def __init__(self, response: httpx.Response | Exception) -> None: + self.response = response + self.calls: list[dict] = [] + + def __call__(self, url, **kwargs): + self.calls.append({"url": url, **kwargs}) + if isinstance(self.response, Exception): + raise self.response + return self.response + + +def respond(status_code: int, *, json=None, text=None) -> httpx.Response: + request = httpx.Request("GET", f"{COLLECTOR_URL}/verify") + if json is not None: + return httpx.Response(status_code, json=json, request=request) + return httpx.Response(status_code, text=text or "", request=request) + + +@pytest.fixture +def transport(monkeypatch): + """Testin kurduğu yanıtı `verify()`ye bağlar.""" + + def install(response): + recorder = Recorder(response) + monkeypatch.setattr(verify_module.httpx, "get", recorder) + return recorder + + return install + + +# --- İstek nasıl kuruluyor ------------------------------------------------- + + +def test_request_goes_to_the_verify_path(transport): + """Adres collector_url + /verify olmalı.""" + recorder = transport(respond(200, json={})) + verify(make_config()) + assert recorder.calls[0]["url"] == f"{COLLECTOR_URL}/verify" + + +def test_trailing_slash_does_not_double_up(transport): + """Kullanıcı adresi `.../` diye yazarsa `//verify` oluşmamalı. + + Çoğu sunucu bunu bağışlar ama bağışlamayan biri 404 döndürür ve kullanıcı + hatayı anahtarında arar. + """ + recorder = transport(respond(200, json={})) + verify(make_config(f"{COLLECTOR_URL}/")) + assert recorder.calls[0]["url"] == f"{COLLECTOR_URL}/verify" + + +def test_key_is_sent_as_a_bearer_token(transport): + """Anahtar `Authorization: Bearer …` başlığında gider.""" + recorder = transport(respond(200, json={})) + verify(make_config()) + assert recorder.calls[0]["headers"]["Authorization"] == f"Bearer {DEVICE_KEY}" + + +def test_key_is_not_put_in_the_url(transport): + """Bu dosyadaki en kritik testlerden biri. + + Anahtar sorgu dizesine kaysaydı test yine geçerdi — collector başlığa da + bakabilir. Ama URL'ler sunucu erişim loglarına, proxy kayıtlarına ve + tarayıcı geçmişine düz metin yazılır; başlık yazılmaz. + """ + recorder = transport(respond(200, json={})) + verify(make_config()) + assert DEVICE_KEY not in recorder.calls[0]["url"] + + +def test_request_has_a_timeout(transport): + """Zaman aşımı olmadan kurulum, cevap vermeyen bir sunucuda süresiz asılırdı.""" + recorder = transport(respond(200, json={})) + verify(make_config()) + assert recorder.calls[0]["timeout"] == REQUEST_TIMEOUT_SECONDS + + +# --- Başarı ---------------------------------------------------------------- + + +def test_200_is_success(transport): + """Anahtar kabul edildiyse kurulum başarılı sayılır.""" + transport(respond(200, json={})) + assert verify(make_config()).ok is True + + +def test_success_reports_the_device_name_and_version(transport): + """Kullanıcı doğru cihaza bağlandığını görebilmeli.""" + transport(respond(200, json={"device_name": "dizustu", "version": "0.3.0"})) + detail = verify(make_config()).detail + assert "dizustu" in detail + assert "0.3.0" in detail + + +@pytest.mark.parametrize( + "response", + [respond(200, text="merhaba"), respond(200, json=["liste"]), respond(200, json={})], + ids=["json-degil", "sozluk-degil", "bos-sozluk"], +) +def test_unexpected_body_does_not_fail_the_check(transport, response): + """Doğrulanan şey anahtarın kabulü, yanıtın şekli değil. + + Gövde beklenmedik geldiğinde `ok=False` dönseydi, çalışan bir kurulum + "bağlanamadı" derdi ve kullanıcı sorunsuz config'ini bozmaya çalışırdı. + """ + transport(response) + assert verify(make_config()).ok is True + + +# --- Başarısızlık ---------------------------------------------------------- + + +def test_401_is_a_failure_about_the_key(transport): + """Reddedilen anahtar, kullanıcıyı doğrudan device_key'e yönlendirmeli.""" + transport(respond(401, json={"detail": "Geçersiz cihaz anahtarı."})) + result = verify(make_config()) + assert result.ok is False + assert "device_key" in result.detail + + +@pytest.mark.parametrize("status_code", [204, 301, 400, 403, 404, 500, 503]) +def test_only_200_counts_as_connected(transport, status_code): + """En kritik test. + + "2xx ise tamam" ya da "401 değilse tamam" gibi gevşek bir kontrol sessizce + yanlış olurdu: 404 (yanlış adres) ya da 503 (collector ayakta değil) alan + kullanıcı "✓ Kuruldu ve bağlandı" görür, sorunun ortaya çıkması ilk verinin + kaybolmasına kadar gecikirdi. + """ + transport(respond(status_code, json={})) + assert verify(make_config()).ok is False + + +def test_unexpected_status_is_reported_with_its_code(transport): + """Teşhis için kodun kendisi mesajda görünmeli.""" + transport(respond(502, json={})) + assert "502" in verify(make_config()).detail + + +@pytest.mark.parametrize( + "error", + [ + httpx.ConnectError("bağlanılamadı"), + httpx.ConnectTimeout("zaman aşımı"), + httpx.ReadTimeout("okuma zaman aşımı"), + httpx.InvalidURL("geçersiz adres"), + ], + ids=["baglanti", "baglanti-zaman-asimi", "okuma-zaman-asimi", "gecersiz-adres"], +) +def test_network_errors_become_a_readable_failure(transport, error): + """Ağ hatası istisna olarak dışarı sızmamalı. + + Sızsaydı `python -m agent --verify` bir Python traceback'i basardı ve + install.sh'in son adımı kullanıcıya hiçbir şey anlatmazdı. + """ + transport(error) + result = verify(make_config()) + assert result.ok is False + assert "collector_url" in result.detail + + +# --- Sızıntı --------------------------------------------------------------- + + +@pytest.mark.parametrize( + "response", + [ + respond(200, json={"device_name": "dizustu", "version": "0.3.0"}), + respond(401, json={}), + respond(500, json={}), + httpx.ConnectError("bağlanılamadı"), + ], + ids=["basarili", "reddedildi", "sunucu-hatasi", "ag-hatasi"], +) +def test_key_never_appears_in_the_result(transport, response): + """Mesaj ekrana basılır ve kullanıcı onu kopyalayıp paylaşır. + + Anahtar mesaja karışsaydı (örneğin "anahtar X reddedildi" gibi yardımsever + bir metinle) sır, destek talebiyle birlikte dışarı çıkardı. + """ + transport(response) + assert DEVICE_KEY not in verify(make_config()).detail