From 816162deb3ba6ae00453c88634342b45a711672d Mon Sep 17 00:00:00 2001 From: denis Date: Mon, 24 Aug 2026 13:55:47 +0300 Subject: [PATCH 1/5] =?UTF-8?q?feat(M5):=20cihaz=20kayd=C4=B1=20=E2=80=94?= =?UTF-8?q?=20POST=20/devices=20+=20user=20JWT=20do=C4=9Frulama?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard'un çağırdığı ilk yazma ucu. Agent uçlarından farklı olarak cihaz anahtarıyla değil kullanıcı JWT'siyle korunur; cihazın anahtarı burada doğar. auth.py — ikinci kimlik modu (require_user): - Supabase access token'ı ES256 ile imzalanır. İmza, projenin JWKS ucundan çekilen AÇIK anahtarla doğrulanır; paylaşılan sır (HS256) kullanılmaz, yani SUPABASE_JWT_SECRET diye bir ortam değişkeni hiç tanımlanmaz. - Açık anahtarlar süreç belleğinde tutulur ve yalnızca TANINMAYAN bir `kid` görüldüğünde yenilenir. İki yenileme arasına 60 saniyelik alt sınır konuldu: uydurma `kid`'lerle gelen istek seli, collector'ı Supabase'e istek üretmeye zorlayamasın. - JWKS çekilemediğinde 401 değil 503 döner — token geçerli olabilir, sorun doğrulayamamaktır; istemcinin oturumunu geçersiz sayması yanlış olurdu. - `exp`, `iss`, `aud`, `sub` zorunlu tutulur ve `role` alanının `authenticated` olduğu ayrıca kontrol edilir: imzası doğru bir servis anahtarı bir son kullanıcıyı temsil etmez. - Reddedilen token loglanmaz, yalnızca hatanın türü yazılır. endpoints_device.py — POST /devices: - Anahtar üretilir, veritabanına yalnızca SHA-256 özeti yazılır; düz hali tek seferlik yanıtta döner ve hiçbir yerde saklanmaz. - Satırın `account_id`'si gövdeden değil doğrulanmış token'dan alınır; kullanıcı cihazı başka bir hesabın altına açamaz. - Gövde `extra="forbid"`: `account_id` ya da `key_hash` yazmayı deneyen istek sessizce yutulmaz, 422 ile reddedilir. - Aynı hesapta aynı ad çakışırsa 409 (kullanıcının düzeltebileceği kalıcı bir durum), diğer veritabanı arızalarında 503. Postgres'in kendi mesajı istemciye geçirilmez. hashing.py: `generate_device_key` — `secrets.token_urlsafe(32)` ile üretilen yüksek entropili anahtar, `tbx_live_` ön ekiyle. supabase_client.py: `insert_device` eklendi. `insert_rows` bu iş için kullanılamaz — o metot çakışan satırı sessizce atlar, cihaz kaydında ise çakışma çağırana bildirilmesi gereken bir hatadır. `SupabaseError` artık Postgres hata kodunu taşıyor (409/503 ayrımı buna dayanıyor). requirements: pyjwt + cryptography (ES256 doğrulaması saf Python'da yapılamaz). --- collector/auth.py | 263 +++++++++++++++++++++++++++++++--- collector/endpoints_device.py | 95 ++++++++++++ collector/hashing.py | 22 ++- collector/main.py | 6 +- collector/requirements.txt | 14 +- collector/supabase_client.py | 75 +++++++++- collector/version.py | 2 +- 7 files changed, 443 insertions(+), 34 deletions(-) create mode 100644 collector/endpoints_device.py 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" From eb673ad9c0dc94e5b353cdf1e2643af8ee92cae8 Mon Sep 17 00:00:00 2001 From: denis Date: Mon, 24 Aug 2026 13:56:02 +0300 Subject: [PATCH 2/5] =?UTF-8?q?feat(M5):=20agent=20ba=C4=9Flant=C4=B1=20te?= =?UTF-8?q?sti=20=E2=80=94=20GET=20/verify=20+=20`--verify`=20modu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kurulumun son adımı 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. core/verify.py: - Yalnızca HTTP 200 "bağlandı" sayılır. Gevşek bir kontrol (2xx ise tamam, 401 değilse tamam) sessizce yanlış olurdu: 404 alan kullanıcı "✓ Kuruldu ve bağlandı" görür, sorun ilk verinin kaybolmasına kadar gecikirdi. - Durum kodları ayrı ayrı ele alınıyor çünkü kullanıcının atacağı adım her birinde farklı: 401 anahtarı, ulaşılamama adresi/ağı, diğerleri collector'ı işaret eder. - Yanıt gövdesi beklenmedik geldiğinde test yine BAŞARILI sayılır; doğrulanan şey anahtarın kabul edilmesi, gövdenin şekli değil. - Anahtar sorgu dizesine değil `Authorization` başlığına konur ve hiçbir mesaja karışmaz: URL'ler erişim loglarına düz metin yazılır, mesajlar da kullanıcı tarafından kopyalanıp paylaşılır. - `httpx.InvalidURL` ayrıca yakalanıyor: `httpx.HTTPError`'un ALTINDA olmadığı için, elle yazılmış bozuk bir collector_url kurulumun son adımında Python traceback'i bastırırdı. __main__.py: - `python -m agent --verify` modu eklendi (install.sh + sonradan teşhis için). - Her iki modda da config önce okunur; hatalıysa agent hiç açılmaz. - Başarısızlık stderr'e yazılır ve ayrı bir çıkış kodu (3) döner, install.sh sonucu ayırt edebilsin. --- agent/__main__.py | 55 ++++++++++++++++++++++---- agent/core/verify.py | 92 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 8 deletions(-) create mode 100644 agent/core/verify.py 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/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}" From 0c066f20f784ef32f00b609a6e29a0d8311d65c0 Mon Sep 17 00:00:00 2001 From: denis Date: Mon, 24 Aug 2026 13:56:29 +0300 Subject: [PATCH 3/5] =?UTF-8?q?feat(M5):=20kurulum=20=E2=80=94=20install.s?= =?UTF-8?q?h,=20uninstall.sh=20ve=20systemd=20unit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kullanıcı dashboard'dan anahtarı alır, install.sh'i indirir ve çalıştırır. İndirilen tek dosya install.sh'tir; agent kaynağını betiğin kendisi çeker (dashboard bunu sonradan bir butona bağlayacak, kullanıcı terminale zorlanmayacak). install.sh — 7 adım: 1. Ön kontrol (Linux, journald, python3 >= 3.11, systemctl, curl) 2. Kaynak indirme — yalnızca HTTPS: `--proto '=https'` sayesinde yönlendirme düz HTTP'ye düşerse indirme reddedilir. Kurulan şey root'un çalıştıracağı koddur; araya giren biri içeriği değiştirebilseydi makineye istediği kodu yazdırırdı. Arşivin beklenen dosyaları taşıdığı ayrıca doğrulanır. 3. Yetkisiz `tracebox` kullanıcısı + dizinler (yalnızca systemd-journal grubuna eklenir; log okumak dışında bir yetkisi yoktur) 4. İzole venv + bağımlılıklar (PEP 668 sorununu aşar) 5. Config oluşturma 6. systemd kurulumu ve servisin başlatılması 7. `python -m agent --verify` ile bağlantı testi Kurulum yarım kalırsa geri alma (rollback) çalışır ve oluşturulan her şey ters sırayla temizlenir — YALNIZCA config yazılana kadar. Anahtar tek başına config.toml'da düz durur; sunucuda özeti, dashboard'da hiçbir şey vardır. Sonraki bir adımın hatası geri almayı tetikleseydi anahtar KALICI olarak kaybolur, kullanıcı cihazı silip baştan oluşturmak zorunda kalırdı. Bu yüzden config yazıldıktan sonra geri alma kapanır ve betik ne kurulduğunu söyleyip çıkar. Sorular stdin'den değil /dev/tty'den okunur: betik `curl … | sudo bash` ile çalıştırıldığında stdin BETİĞİN KENDİSİDİR; `read` stdin'den okusaydı sorunun cevabı olarak betiğin bir sonraki satırını yutardı. Anahtar `read -s` ile alınır, ekrana basılmaz ve hiçbir çıktıda tekrarlanmaz. config.toml `chmod 600` ile kilitlenir. tracebox-agent.service: - `User=tracebox` — servis root olarak çalışmaz. - ExecStart venv'in Python'ını kullanır; sistem python3'ü psutil ve httpx'i görmez. - ProtectSystem=strict + ReadWritePaths=/var/lib/tracebox: agent'ın yazabildiği tek yer state.json ve spool'dur. - NoNewPrivileges, ProtectHome, PrivateTmp, ProtectKernel*, RestrictAddressFamilies. - Çökme döngüsü freni [Unit] bölümünde: systemd v230'dan beri StartLimitIntervalSec/StartLimitBurst [Service] altında YOK SAYILIR. uninstall.sh servisi durdurur, üç dizini de siler (anahtar dahil) ve sistem kullanıcısını kaldırır. Yeniden çalıştırılabilir. `delete` komutu M6'da bu betiği çağıracak. Betikler tek seferlik atılabilir bir Docker konteynerinde uçtan uca çalıştırılarak doğrulandı: gerçek kullanıcı/dizin/venv oluşturma ve ardından tam geri alma. --- agent/install.sh | 376 +++++++++++++++++++++++++++++++++++ agent/tracebox-agent.service | 60 ++++++ agent/uninstall.sh | 89 +++++++++ 3 files changed, 525 insertions(+) create mode 100755 agent/install.sh create mode 100644 agent/tracebox-agent.service create mode 100755 agent/uninstall.sh 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' From 32c4496db32df77599e758ac87c6f2dfedef6de9 Mon Sep 17 00:00:00 2001 From: denis Date: Mon, 24 Aug 2026 13:56:45 +0300 Subject: [PATCH 4/5] =?UTF-8?q?fix(agent):=20config=20izinleri=20sonradan?= =?UTF-8?q?=20gev=C5=9Fetilse=20de=20fark=20edilsin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. İki değişiklik: 1. `check_permissions` — sahibi dışında herhangi biri için herhangi bir bit açıksa (grup dahil, yazma dahil) uyarı basılır. Yalnızca "diğerleri" bitlerine bakan bir kontrol 640'ı temiz sayardı; yalnızca okuma bitine bakan bir kontrol 620'yi temiz sayardı — oysa anahtarı saldırganın kendi anahtarıyla değiştirmek de bir saldırı yoludur. Agent DURDURULMAZ. Çalışan bir izleme aracını izin biti yüzünden öldürmek makineyi tamamen gözsüz bırakır; uyarı journald'a düşer ve düzeltme komutu (`chmod 600 `) mesajın içinde verilir. 2. Önbellek imzasına `st_mode` eklendi. `chmod` dosyanın ne mtime'ını ne de boyutunu değiştirir; imza yalnızca bu ikisine baksaydı açılışta 600 olan bir dosya sonradan 644 yapıldığında agent bunu ömür boyu fark etmezdi. Uyarı hâlâ yalnızca dosya (yeniden) okunurken basılıyor, her tick'te değil. --- agent/core/config.py | 41 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) 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) From 782300f522632764d839f7d8c5db8361e2fb96f8 Mon Sep 17 00:00:00 2001 From: denis Date: Mon, 24 Aug 2026 13:57:19 +0300 Subject: [PATCH 5/5] =?UTF-8?q?test(M5):=20138=20test=20=E2=80=94=20cihaz?= =?UTF-8?q?=20kayd=C4=B1,=20ba=C4=9Flant=C4=B1=20testi,=20izinler=20ve=20k?= =?UTF-8?q?urulum=20betikleri?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M5'in tamamı testlerle kapatıldı. Her test, koruduğu kod bilerek bozularak doğrulandı: 38 sabotaj denemesinin 38'i yakalandı. "Yeşil" tek başına kanıt sayılmadı. test_auth_user.py (30) — user JWT doğrulaması. Gerçek ES256 anahtarlarıyla, ağa çıkmadan: JWKS belgesini yerelde bir HTTP sunucusu yayınlıyor. - Başka bir anahtarla imzalanmış token reddedilir (en kritik iddia). - Elle kurulmuş saldırı token'ları: `alg:none` ve açık anahtarı HMAC sırrı gibi kullanan HS256. Bu ikisi iki ayrı katmanda reddediliyor, bu yüzden izin listesinin kendisi ayrıca doğrudan sınanıyor — yoksa listeye HS256 eklenmesi davranış testlerinden kaçardı. - Süresi geçmiş / `exp`siz / `sub`suz / yanlış `aud` / yanlış `iss` / yanlış `role` token'ları; bozuk başlık biçimleri. - Önbellek gerçekten bir kez çekiyor; 20 uydurma `kid` seli Supabase'e tek istekten fazlasını üretmiyor. - Reddetme mesajı her hatada aynı ve token loga düşmüyor. test_endpoints_device.py (25) — POST /devices. - Düz anahtar hiçbir koşulda satıra yazılmıyor; saklanan özet dönen anahtarın özeti. - Gövdeye `account_id`/`key_hash`/`device_id` yazma denemesi 422. - Ad kırpma, uzunluk sınırı (sınırın kendisi geçerli), boş/boşluk reddi. - 409 ile 503 ayrımı; Postgres'in kendi mesajı yanıta sızmıyor. - Kimlik doğrulaması olmadan 401 ve satır oluşmuyor. test_verify.py (27) — bağlantı testi. - Yalnızca 200 "bağlandı" sayılıyor (204/301/403/404/500/503 dahil hepsi başarısız). - Anahtar URL'e girmiyor, hiçbir mesajda görünmüyor. - Ağ hataları okunabilir sonuca dönüşüyor, istisna dışarı sızmıyor. test_config_permissions.py (18) — izin denetimi. - 640/644/660/604/… hepsi işaretleniyor; 600 sessiz. - Gevşek izin agent'ı durdurmuyor, uyarı her tick tekrarlanmıyor. - Açılıştan SONRA gevşetilen izin fark ediliyor. test_install_scripts.py (33) — betikler birim testinde çalıştırılamaz (kullanıcı oluşturur, sistem dizinlerine yazar, systemd'ye dokunur), bu yüzden statik sözleşme kontrolü: `bash -n`, `set -euo pipefail`, `chmod 600`, `read -s`, `/dev/tty`, yalnızca HTTPS, `User=tracebox`, venv yorumlayıcısı, NoNewPrivileges, çökme freninin [Unit] altında olması, uninstall'ın üç dizini de silmesi. İddialar yorum satırlarıyla tatmin edilemiyor. Ayrıca TRACEBOX_CONFIG / TRACEBOX_STATE_DIR override'larının kurulum betiğine ya da unit dosyasına sızmadığı kontrol ediliyor: sızsalardı üretim agent'ı /etc/tracebox yerine başka bir yolu okur ve hata vermeden yanlış ayarla çalışırdı. test_hashing.py (+5) — `generate_device_key`: ön ek, benzersizlik (1000 anahtar), entropi uzunluğu ve anahtarın `random` modülünden gelmediği. Sonuncusu için başlangıç değeri aynı noktaya iki kez sabitlenip iki anahtar isteniyor; kaynak `random` olsaydı ikisi birebir aynı çıkardı. Bu tur iki gerçek kusur ortaya çıkardı: yakalanmayan `httpx.InvalidURL` (ayrı commit'te düzeltildi) ve hiçbir bozulmada kırmızıya dönemeyen bir testim — dosya türü bitleri izin maskesiyle hiç kesişmediği için silindi. Toplam: 226 test. --- tests/test_auth_user.py | 418 +++++++++++++++++++++++++++++++ tests/test_config_permissions.py | 200 +++++++++++++++ tests/test_endpoints_device.py | 249 ++++++++++++++++++ tests/test_hashing.py | 68 ++++- tests/test_install_scripts.py | 288 +++++++++++++++++++++ tests/test_verify.py | 212 ++++++++++++++++ 6 files changed, 1433 insertions(+), 2 deletions(-) create mode 100644 tests/test_auth_user.py create mode 100644 tests/test_config_permissions.py create mode 100644 tests/test_endpoints_device.py create mode 100644 tests/test_install_scripts.py create mode 100644 tests/test_verify.py 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