Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 47 additions & 8 deletions agent/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -13,30 +17,49 @@
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ı
# her kod yeniden başlatma tetikler.
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):
Expand All @@ -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())
41 changes: 38 additions & 3 deletions agent/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

import os
import stat
import tomllib
from dataclasses import dataclass
from pathlib import Path
Expand All @@ -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")
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand All @@ -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)

Expand Down
92 changes: 92 additions & 0 deletions agent/core/verify.py
Original file line number Diff line number Diff line change
@@ -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}"
Loading
Loading