Skip to content

Commit b35a7c9

Browse files
committed
Feat: Add AsyncClient for async applications
- Add AsyncClient class using httpx for async operations - Support context manager (async with) - Add bulk_export_stream async generator - Add httpx dependency - Update README with async examples
1 parent 1057ac9 commit b35a7c9

4 files changed

Lines changed: 312 additions & 0 deletions

File tree

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,32 @@ for event in results.json():
2929
results = client.search("+country:FR +port:22", scope="service")
3030
```
3131

32+
## Async Client
33+
34+
For async applications, use `AsyncClient`:
35+
36+
```python
37+
import asyncio
38+
from leakix import AsyncClient
39+
40+
async def main():
41+
async with AsyncClient(api_key="your-api-key") as client:
42+
# Simple search
43+
results = await client.search("+plugin:GitConfigHttpPlugin", scope="leak")
44+
for event in results:
45+
print(event.ip, event.host)
46+
47+
# Host lookup
48+
host = await client.get_host("8.8.8.8")
49+
print(host["services"])
50+
51+
# Streaming bulk export
52+
async for aggregation in client.bulk_export_stream(queries):
53+
print(aggregation.events[0].ip)
54+
55+
asyncio.run(main())
56+
```
57+
3258
## Documentation
3359

3460
Docstrings are used to document the library.

leakix/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from importlib.metadata import version
22

3+
from leakix.async_client import AsyncClient as AsyncClient
34
from leakix.client import Client as Client
45
from leakix.client import HostResult as HostResult
56
from leakix.client import Scope as Scope
@@ -71,6 +72,7 @@
7172

7273
__all__ = [
7374
"__version__",
75+
"AsyncClient",
7476
"Client",
7577
"HostResult",
7678
"L9Subdomain",

leakix/async_client.py

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
"""Async LeakIX API client using httpx."""
2+
3+
import json
4+
from typing import AsyncIterator
5+
6+
import httpx
7+
from l9format import l9format
8+
9+
from leakix.domain import L9Subdomain
10+
from leakix.plugin import APIResult
11+
from leakix.query import EmptyQuery, Query, RawQuery
12+
13+
__VERSION__ = "0.2.0"
14+
15+
DEFAULT_URL = "https://leakix.net"
16+
DEFAULT_TIMEOUT = 30.0
17+
18+
19+
class AsyncClient:
20+
"""Async client for the LeakIX API."""
21+
22+
MAX_RESULTS_PER_PAGE = 20
23+
24+
def __init__(
25+
self,
26+
api_key: str | None = None,
27+
base_url: str | None = DEFAULT_URL,
28+
timeout: float = DEFAULT_TIMEOUT,
29+
):
30+
self.api_key = api_key
31+
self.base_url = base_url if base_url else DEFAULT_URL
32+
self.timeout = timeout
33+
self.headers = {
34+
"Accept": "application/json",
35+
"User-agent": f"leakix-client-python/{__VERSION__}",
36+
}
37+
if api_key:
38+
self.headers["api-key"] = api_key
39+
self._client: httpx.AsyncClient | None = None
40+
41+
async def _get_client(self) -> httpx.AsyncClient:
42+
"""Get or create the HTTP client."""
43+
if self._client is None or self._client.is_closed:
44+
self._client = httpx.AsyncClient(
45+
base_url=self.base_url,
46+
headers=self.headers,
47+
timeout=self.timeout,
48+
)
49+
return self._client
50+
51+
async def close(self) -> None:
52+
"""Close the HTTP client."""
53+
if self._client is not None and not self._client.is_closed:
54+
await self._client.aclose()
55+
self._client = None
56+
57+
async def __aenter__(self) -> "AsyncClient":
58+
return self
59+
60+
async def __aexit__(self, *args) -> None:
61+
await self.close()
62+
63+
async def _get(
64+
self,
65+
path: str,
66+
params: dict | None = None,
67+
) -> tuple[int, dict | list | None]:
68+
"""Make a GET request and return status code and JSON response."""
69+
client = await self._get_client()
70+
response = await client.get(path, params=params)
71+
if response.status_code == 204:
72+
return response.status_code, []
73+
if response.status_code == 200:
74+
return response.status_code, response.json() if response.content else []
75+
if response.status_code == 429:
76+
return response.status_code, None
77+
return response.status_code, response.json()
78+
79+
async def get(
80+
self,
81+
scope: str,
82+
queries: list[Query] | None = None,
83+
page: int = 0,
84+
) -> list[l9format.L9Event]:
85+
"""
86+
Search LeakIX for services or leaks.
87+
88+
Args:
89+
scope: Either "service" or "leak".
90+
queries: List of Query objects.
91+
page: Page number (0-indexed).
92+
93+
Returns:
94+
List of L9Event results.
95+
"""
96+
if page < 0:
97+
raise ValueError("Page argument must be a positive integer")
98+
if queries is None or len(queries) == 0:
99+
serialized_query = EmptyQuery().serialize()
100+
else:
101+
serialized_query = [q.serialize() for q in queries]
102+
serialized_query = " ".join(serialized_query)
103+
serialized_query = f"{serialized_query}"
104+
105+
status, data = await self._get(
106+
"/search",
107+
params={"scope": scope, "q": serialized_query, "page": page},
108+
)
109+
if status == 200 and isinstance(data, list):
110+
return [l9format.L9Event.from_dict(item) for item in data]
111+
return []
112+
113+
async def get_service(
114+
self,
115+
queries: list[Query] | None = None,
116+
page: int = 0,
117+
) -> list[l9format.L9Event]:
118+
"""Shortcut for get with scope='service'."""
119+
return await self.get("service", queries=queries, page=page)
120+
121+
async def get_leak(
122+
self,
123+
queries: list[Query] | None = None,
124+
page: int = 0,
125+
) -> list[l9format.L9Event]:
126+
"""Shortcut for get with scope='leak'."""
127+
return await self.get("leak", queries=queries, page=page)
128+
129+
async def search(
130+
self,
131+
query: str,
132+
scope: str = "leak",
133+
page: int = 0,
134+
) -> list[l9format.L9Event]:
135+
"""
136+
Simple search using a query string.
137+
138+
Args:
139+
query: Search query string (same syntax as website).
140+
scope: Either "leak" or "service" (default: "leak").
141+
page: Page number for pagination (default: 0).
142+
143+
Returns:
144+
List of L9Event results.
145+
"""
146+
queries = [RawQuery(query)]
147+
if scope == "service":
148+
return await self.get_service(queries=queries, page=page)
149+
return await self.get_leak(queries=queries, page=page)
150+
151+
async def get_host(self, ip: str) -> dict:
152+
"""
153+
Get services and leaks for a specific IP address.
154+
155+
Args:
156+
ip: IPv4 or IPv6 address.
157+
158+
Returns:
159+
Dict with 'services' and 'leaks' lists.
160+
"""
161+
status, data = await self._get(f"/host/{ip}")
162+
if status == 200 and isinstance(data, dict):
163+
services = data.get("Services") or []
164+
leaks = data.get("Leaks") or []
165+
return {
166+
"services": [l9format.L9Event.from_dict(s) for s in services],
167+
"leaks": [l9format.L9Event.from_dict(l) for l in leaks],
168+
}
169+
return {"services": [], "leaks": []}
170+
171+
async def get_domain(self, domain: str) -> dict:
172+
"""
173+
Get services and leaks for a specific domain.
174+
175+
Args:
176+
domain: Domain name.
177+
178+
Returns:
179+
Dict with 'services' and 'leaks' lists.
180+
"""
181+
status, data = await self._get(f"/domain/{domain}")
182+
if status == 200 and isinstance(data, dict):
183+
services = data.get("Services") or []
184+
leaks = data.get("Leaks") or []
185+
return {
186+
"services": [l9format.L9Event.from_dict(s) for s in services],
187+
"leaks": [l9format.L9Event.from_dict(l) for l in leaks],
188+
}
189+
return {"services": [], "leaks": []}
190+
191+
async def get_subdomains(self, domain: str) -> list[L9Subdomain]:
192+
"""
193+
Get subdomains for a domain.
194+
195+
Args:
196+
domain: Domain name.
197+
198+
Returns:
199+
List of L9Subdomain objects.
200+
"""
201+
status, data = await self._get(f"/api/subdomains/{domain}")
202+
if status == 200 and isinstance(data, list):
203+
return [L9Subdomain.from_dict(d) for d in data]
204+
return []
205+
206+
async def get_plugins(self) -> list[APIResult]:
207+
"""
208+
Get list of available plugins.
209+
210+
Returns:
211+
List of APIResult objects.
212+
"""
213+
status, data = await self._get("/api/plugins")
214+
if status == 200 and isinstance(data, list):
215+
return [APIResult.from_dict(d) for d in data]
216+
return []
217+
218+
async def bulk_export(
219+
self,
220+
queries: list[Query] | None = None,
221+
) -> list[l9format.L9Aggregation]:
222+
"""
223+
Bulk export leaks (Pro API feature).
224+
225+
Args:
226+
queries: List of Query objects.
227+
228+
Returns:
229+
List of L9Aggregation results.
230+
"""
231+
if queries is None or len(queries) == 0:
232+
serialized_query = EmptyQuery().serialize()
233+
else:
234+
serialized_query = [q.serialize() for q in queries]
235+
serialized_query = " ".join(serialized_query)
236+
serialized_query = f"{serialized_query}"
237+
238+
client = await self._get_client()
239+
results: list[l9format.L9Aggregation] = []
240+
241+
async with client.stream(
242+
"GET", "/bulk/search", params={"q": serialized_query}
243+
) as response:
244+
if response.status_code != 200:
245+
return results
246+
async for line in response.aiter_lines():
247+
if line:
248+
data = json.loads(line)
249+
results.append(l9format.L9Aggregation.from_dict(data))
250+
251+
return results
252+
253+
async def bulk_export_stream(
254+
self,
255+
queries: list[Query] | None = None,
256+
) -> AsyncIterator[l9format.L9Aggregation]:
257+
"""
258+
Streaming bulk export. Yields L9Aggregation objects one by one.
259+
260+
Args:
261+
queries: List of Query objects.
262+
263+
Yields:
264+
L9Aggregation objects as they arrive.
265+
"""
266+
if queries is None or len(queries) == 0:
267+
serialized_query = EmptyQuery().serialize()
268+
else:
269+
serialized_query = [q.serialize() for q in queries]
270+
serialized_query = " ".join(serialized_query)
271+
serialized_query = f"{serialized_query}"
272+
273+
client = await self._get_client()
274+
275+
async with client.stream(
276+
"GET", "/bulk/search", params={"q": serialized_query}
277+
) as response:
278+
if response.status_code != 200:
279+
return
280+
async for line in response.aiter_lines():
281+
if line:
282+
data = json.loads(line)
283+
yield l9format.L9Aggregation.from_dict(data)

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ authors = [
1010
[tool.poetry.dependencies]
1111
python = "^3.13"
1212
requests = "*"
13+
httpx = "^0.28.0"
1314
l9format = "=1.4.0"
1415
fire = ">=0.5,<0.8"
1516

0 commit comments

Comments
 (0)