feat(smtp_relay): move probe forwarding to realism worker via bus

Attacker probe emails are now forwarded by the master (realism worker)
rather than inside the MACVLAN container, which has no internet gateway.

- New smtp.probe.pending bus topic: ingester publishes when smtp_relay
  message_stored fires; worker subscribes and does the actual delivery
- decnet/orchestrator/drivers/smtp_relay.py: pure-sync forward_probe()
  reads the .eml from disk and sends via smtplib on a thread executor
- worker.py: _run_smtp_probe_listener + _handle_probe_pending subtask;
  limit enforced via count_probe_relays() (DB-backed, restart-safe)
- bounties.py: count_probe_relays() query on probe_relay bounty type
- fleet.py: get_fleet_decky_by_name() to pull service config from DB
- services/smtp_relay.py: upstream_* and probe_limit fields defined in
  config_schema but NOT injected into container env (credentials stay
  out of docker env vars)
- ingester.py: stripped of smtplib; publishes probe.pending and exits
- tests: assert upstream keys absent from container environment
This commit is contained in:
2026-04-30 12:10:58 -04:00
parent 4c0a1309f0
commit 8ae7b9636e
8 changed files with 231 additions and 39 deletions

View File

@@ -137,3 +137,15 @@ class BountiesMixin:
pass
grouped[item.attacker_ip].append(d)
return dict(grouped)
async def count_probe_relays(self, attacker_ip: str, decky: str) -> int:
"""Return how many probe_relay bounties exist for this (attacker_ip, decky) pair."""
async with self._session() as session:
result = await session.execute(
select(func.count()).select_from(Bounty).where(
Bounty.attacker_ip == attacker_ip,
Bounty.decky == decky,
Bounty.bounty_type == "probe_relay",
)
)
return result.scalar() or 0

View File

@@ -59,6 +59,16 @@ class FleetMixin:
)
await session.commit()
async def get_fleet_decky_by_name(self, name: str) -> dict[str, Any] | None:
async with self._session() as session:
result = await session.execute(
select(FleetDecky).where(FleetDecky.name == name)
)
row = result.scalar_one_or_none()
if row is None:
return None
return _deserialize_json_fields(row.model_dump(mode="json"), ("services", "decky_config"))
async def list_fleet_deckies(
self, *, host_uuid: Optional[str] = None,
) -> list[dict[str, Any]]: