cnsl 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cnsl/__init__.py +8 -0
- cnsl/__main__.py +5 -0
- cnsl/agent.py +352 -0
- cnsl/api.py +88 -0
- cnsl/assets.py +307 -0
- cnsl/auth.py +461 -0
- cnsl/blocker.py +173 -0
- cnsl/cases.py +423 -0
- cnsl/config.py +551 -0
- cnsl/correlator.py +351 -0
- cnsl/dashboard.py +3175 -0
- cnsl/detector.py +833 -0
- cnsl/engine.py +453 -0
- cnsl/fim.py +494 -0
- cnsl/geoip.py +168 -0
- cnsl/grafana.py +367 -0
- cnsl/honeypot.py +1099 -0
- cnsl/huddle_integration.py +412 -0
- cnsl/kafka_consumer.py +323 -0
- cnsl/log_sources.py +585 -0
- cnsl/logger.py +118 -0
- cnsl/metrics.py +109 -0
- cnsl/ml_detector.py +375 -0
- cnsl/models.py +80 -0
- cnsl/normalizer.py +480 -0
- cnsl/notify.py +332 -0
- cnsl/parsers.py +161 -0
- cnsl/rate_limiter.py +260 -0
- cnsl/rbac.py +166 -0
- cnsl/redis_sync.py +329 -0
- cnsl/reporter.py +592 -0
- cnsl/rules.py +337 -0
- cnsl/search_engine.py +412 -0
- cnsl/sources.py +127 -0
- cnsl/store.py +251 -0
- cnsl/syslog_receiver.py +350 -0
- cnsl/tenants.py +230 -0
- cnsl/threat_feed.py +360 -0
- cnsl/threat_intel.py +299 -0
- cnsl/ueba.py +454 -0
- cnsl/validator.py +167 -0
- cnsl/zeek_parser.py +425 -0
- cnsl-2.0.0.dist-info/METADATA +1246 -0
- cnsl-2.0.0.dist-info/RECORD +48 -0
- cnsl-2.0.0.dist-info/WHEEL +5 -0
- cnsl-2.0.0.dist-info/entry_points.txt +2 -0
- cnsl-2.0.0.dist-info/licenses/LICENSE +21 -0
- cnsl-2.0.0.dist-info/top_level.txt +1 -0
cnsl/__init__.py
ADDED
cnsl/__main__.py
ADDED
cnsl/agent.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cnsl/agent.py — CNSL Log Forwarding Agent.
|
|
3
|
+
|
|
4
|
+
A lightweight agent that runs on remote servers and forwards log events
|
|
5
|
+
to a central CNSL instance via WebSocket.
|
|
6
|
+
|
|
7
|
+
Features:
|
|
8
|
+
- Tails auth.log, nginx, apache, mysql, ufw, syslog (configurable)
|
|
9
|
+
- Forwards parsed events to CNSL server over WebSocket
|
|
10
|
+
- Automatic reconnection with exponential backoff
|
|
11
|
+
- JWT authentication (uses CNSL API token)
|
|
12
|
+
- Backpressure: drops oldest events if queue fills up
|
|
13
|
+
- TLS support (wss://)
|
|
14
|
+
- Agent hostname tagged on every event
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
python -m cnsl.agent --server wss://cnsl.example.com/ws/agent \\
|
|
18
|
+
--token YOUR_JWT_TOKEN \\
|
|
19
|
+
--hostname web-01
|
|
20
|
+
|
|
21
|
+
Config file (~/.cnsl-agent.json or /etc/cnsl/agent.json):
|
|
22
|
+
{
|
|
23
|
+
"server": "wss://cnsl.example.com/ws/agent",
|
|
24
|
+
"token": "your-jwt-token",
|
|
25
|
+
"hostname": "web-01",
|
|
26
|
+
"sources": {
|
|
27
|
+
"auth": "/var/log/auth.log",
|
|
28
|
+
"nginx": "/var/log/nginx/access.log",
|
|
29
|
+
"apache": null,
|
|
30
|
+
"mysql": null,
|
|
31
|
+
"ufw": "/var/log/ufw.log",
|
|
32
|
+
"syslog": "/var/log/syslog"
|
|
33
|
+
},
|
|
34
|
+
"queue_size": 1000,
|
|
35
|
+
"batch_size": 50,
|
|
36
|
+
"flush_interval": 1.0
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
Running as a systemd service:
|
|
40
|
+
See docs/agent.md for a complete setup guide.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import asyncio
|
|
46
|
+
import json
|
|
47
|
+
import os
|
|
48
|
+
import platform
|
|
49
|
+
import socket
|
|
50
|
+
import sys
|
|
51
|
+
import time
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
from typing import Any, Dict, List, Optional
|
|
54
|
+
|
|
55
|
+
from .models import iso_time
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Agent config defaults
|
|
59
|
+
|
|
60
|
+
DEFAULT_AGENT_CONFIG: Dict[str, Any] = {
|
|
61
|
+
"server": "ws://localhost:8765/ws/agent",
|
|
62
|
+
"token": "",
|
|
63
|
+
"hostname": socket.gethostname(),
|
|
64
|
+
"sources": {
|
|
65
|
+
"auth": "/var/log/auth.log",
|
|
66
|
+
"nginx": None,
|
|
67
|
+
"apache": None,
|
|
68
|
+
"mysql": None,
|
|
69
|
+
"ufw": None,
|
|
70
|
+
"syslog": None,
|
|
71
|
+
},
|
|
72
|
+
"queue_size": 1000,
|
|
73
|
+
"batch_size": 50,
|
|
74
|
+
"flush_interval": 1.0,
|
|
75
|
+
"reconnect_min": 2.0,
|
|
76
|
+
"reconnect_max": 60.0,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
_CONFIG_PATHS = [
|
|
80
|
+
Path.home() / ".cnsl-agent.json",
|
|
81
|
+
Path("/etc/cnsl/agent.json"),
|
|
82
|
+
Path("cnsl-agent.json"),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def load_agent_config(path: Optional[str] = None) -> Dict[str, Any]:
|
|
87
|
+
"""Load agent config from file, merging with defaults."""
|
|
88
|
+
cfg = dict(DEFAULT_AGENT_CONFIG)
|
|
89
|
+
cfg["sources"] = dict(DEFAULT_AGENT_CONFIG["sources"])
|
|
90
|
+
|
|
91
|
+
search = [Path(path)] if path else _CONFIG_PATHS
|
|
92
|
+
for p in search:
|
|
93
|
+
if p.exists():
|
|
94
|
+
try:
|
|
95
|
+
loaded = json.loads(p.read_text())
|
|
96
|
+
cfg.update({k: v for k, v in loaded.items() if k != "sources"})
|
|
97
|
+
if "sources" in loaded:
|
|
98
|
+
cfg["sources"].update(loaded["sources"])
|
|
99
|
+
except Exception:
|
|
100
|
+
pass
|
|
101
|
+
break
|
|
102
|
+
|
|
103
|
+
return cfg
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# Event queue
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class AgentQueue:
|
|
110
|
+
"""Bounded queue with drop-oldest on overflow."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, maxsize: int = 1000):
|
|
113
|
+
self._q: asyncio.Queue = asyncio.Queue(maxsize=maxsize)
|
|
114
|
+
self.dropped: int = 0
|
|
115
|
+
|
|
116
|
+
def put_nowait(self, item: Any) -> None:
|
|
117
|
+
try:
|
|
118
|
+
self._q.put_nowait(item)
|
|
119
|
+
except asyncio.QueueFull:
|
|
120
|
+
try:
|
|
121
|
+
self._q.get_nowait() # drop oldest
|
|
122
|
+
self.dropped += 1
|
|
123
|
+
except asyncio.QueueEmpty:
|
|
124
|
+
pass
|
|
125
|
+
self._q.put_nowait(item)
|
|
126
|
+
|
|
127
|
+
async def get_batch(self, max_items: int, timeout: float) -> List[Any]:
|
|
128
|
+
items = []
|
|
129
|
+
deadline = time.monotonic() + timeout
|
|
130
|
+
while len(items) < max_items:
|
|
131
|
+
remaining = deadline - time.monotonic()
|
|
132
|
+
if remaining <= 0:
|
|
133
|
+
break
|
|
134
|
+
try:
|
|
135
|
+
item = await asyncio.wait_for(self._q.get(), timeout=remaining)
|
|
136
|
+
items.append(item)
|
|
137
|
+
except asyncio.TimeoutError:
|
|
138
|
+
break
|
|
139
|
+
return items
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def qsize(self) -> int:
|
|
143
|
+
return self._q.qsize()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# Log tailer
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def tail_file(path: str, queue: AgentQueue, parser_fn, hostname: str) -> None:
|
|
150
|
+
"""
|
|
151
|
+
Tail a log file and push parsed events to the queue.
|
|
152
|
+
Handles log rotation by re-opening when the file shrinks.
|
|
153
|
+
"""
|
|
154
|
+
pos = 0
|
|
155
|
+
while True:
|
|
156
|
+
try:
|
|
157
|
+
if not os.path.exists(path):
|
|
158
|
+
await asyncio.sleep(5)
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
stat = os.stat(path)
|
|
162
|
+
|
|
163
|
+
# Detect rotation (file shrank or was replaced)
|
|
164
|
+
if stat.st_size < pos:
|
|
165
|
+
pos = 0
|
|
166
|
+
|
|
167
|
+
if stat.st_size == pos:
|
|
168
|
+
await asyncio.sleep(0.5)
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
with open(path, "r", errors="replace") as f:
|
|
172
|
+
f.seek(pos)
|
|
173
|
+
for line in f:
|
|
174
|
+
line = line.rstrip("\n")
|
|
175
|
+
if not line:
|
|
176
|
+
continue
|
|
177
|
+
try:
|
|
178
|
+
ev = parser_fn(line)
|
|
179
|
+
if ev:
|
|
180
|
+
payload = ev.to_dict() if hasattr(ev, "to_dict") else {"raw": line}
|
|
181
|
+
payload["_agent_host"] = hostname
|
|
182
|
+
payload["_source_file"] = path
|
|
183
|
+
queue.put_nowait(payload)
|
|
184
|
+
except Exception:
|
|
185
|
+
pass
|
|
186
|
+
pos = f.tell()
|
|
187
|
+
|
|
188
|
+
except Exception:
|
|
189
|
+
await asyncio.sleep(2)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# WebSocket sender
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def ws_sender(cfg: Dict[str, Any], queue: AgentQueue) -> None:
|
|
196
|
+
"""
|
|
197
|
+
Main WebSocket sender loop.
|
|
198
|
+
Connects to the CNSL server, authenticates, and flushes event batches.
|
|
199
|
+
Reconnects with exponential backoff on failure.
|
|
200
|
+
"""
|
|
201
|
+
try:
|
|
202
|
+
import aiohttp
|
|
203
|
+
except ImportError:
|
|
204
|
+
print("ERROR: aiohttp not installed. Run: pip install aiohttp", file=sys.stderr)
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
server = cfg["server"]
|
|
208
|
+
token = cfg["token"]
|
|
209
|
+
hostname = cfg["hostname"]
|
|
210
|
+
batch = int(cfg.get("batch_size", 50))
|
|
211
|
+
interval = float(cfg.get("flush_interval", 1.0))
|
|
212
|
+
backoff = float(cfg.get("reconnect_min", 2.0))
|
|
213
|
+
max_back = float(cfg.get("reconnect_max", 60.0))
|
|
214
|
+
|
|
215
|
+
while True:
|
|
216
|
+
try:
|
|
217
|
+
timeout = aiohttp.ClientTimeout(total=None, connect=10)
|
|
218
|
+
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
219
|
+
async with session.ws_connect(
|
|
220
|
+
server,
|
|
221
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
222
|
+
heartbeat=30,
|
|
223
|
+
) as ws:
|
|
224
|
+
# Send agent handshake
|
|
225
|
+
await ws.send_json({
|
|
226
|
+
"type": "agent_hello",
|
|
227
|
+
"hostname": hostname,
|
|
228
|
+
"version": "1.9.0",
|
|
229
|
+
"pid": os.getpid(),
|
|
230
|
+
"platform": platform.system(),
|
|
231
|
+
"time": iso_time(),
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
# Wait for server ack
|
|
235
|
+
msg = await asyncio.wait_for(ws.receive(), timeout=10)
|
|
236
|
+
if msg.type != aiohttp.WSMsgType.TEXT:
|
|
237
|
+
raise ConnectionError("Bad handshake response")
|
|
238
|
+
ack = json.loads(msg.data)
|
|
239
|
+
if not ack.get("ok"):
|
|
240
|
+
raise ConnectionError(f"Server rejected: {ack.get('error')}")
|
|
241
|
+
|
|
242
|
+
print(f"[agent] Connected to {server} as {hostname}", file=sys.stderr)
|
|
243
|
+
backoff = float(cfg.get("reconnect_min", 2.0)) # reset on success
|
|
244
|
+
|
|
245
|
+
# Flush loop
|
|
246
|
+
while True:
|
|
247
|
+
events = await queue.get_batch(batch, interval)
|
|
248
|
+
if events:
|
|
249
|
+
await ws.send_json({
|
|
250
|
+
"type": "agent_events",
|
|
251
|
+
"host": hostname,
|
|
252
|
+
"count": len(events),
|
|
253
|
+
"events": events,
|
|
254
|
+
})
|
|
255
|
+
else:
|
|
256
|
+
# Keepalive ping
|
|
257
|
+
await ws.ping()
|
|
258
|
+
|
|
259
|
+
except (ConnectionError, OSError, asyncio.TimeoutError) as exc:
|
|
260
|
+
print(f"[agent] Disconnected ({exc}). Retry in {backoff:.0f}s", file=sys.stderr)
|
|
261
|
+
except Exception as exc:
|
|
262
|
+
print(f"[agent] Error: {exc}. Retry in {backoff:.0f}s", file=sys.stderr)
|
|
263
|
+
|
|
264
|
+
await asyncio.sleep(backoff)
|
|
265
|
+
backoff = min(backoff * 1.5, max_back)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# Agent entrypoint
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
async def run_agent(cfg: Dict[str, Any]) -> None:
|
|
272
|
+
"""Start all log tailers and the WebSocket sender."""
|
|
273
|
+
from .parsers import parse_auth_event
|
|
274
|
+
from .log_sources import parse_web_access, parse_mysql, parse_ufw, parse_syslog
|
|
275
|
+
|
|
276
|
+
hostname = cfg["hostname"]
|
|
277
|
+
queue = AgentQueue(maxsize=int(cfg.get("queue_size", 1000)))
|
|
278
|
+
sources = cfg.get("sources", {})
|
|
279
|
+
|
|
280
|
+
parsers = {
|
|
281
|
+
"auth": parse_auth_event,
|
|
282
|
+
"nginx": lambda l: parse_web_access(l, "nginx"),
|
|
283
|
+
"apache": lambda l: parse_web_access(l, "apache"),
|
|
284
|
+
"mysql": parse_mysql,
|
|
285
|
+
"ufw": parse_ufw,
|
|
286
|
+
"syslog": parse_syslog,
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
tasks = []
|
|
290
|
+
for name, path in sources.items():
|
|
291
|
+
if not path or not isinstance(path, str):
|
|
292
|
+
continue
|
|
293
|
+
parser = parsers.get(name)
|
|
294
|
+
if not parser:
|
|
295
|
+
continue
|
|
296
|
+
if not os.path.exists(path):
|
|
297
|
+
print(f"[agent] Warning: {path} not found — skipping", file=sys.stderr)
|
|
298
|
+
continue
|
|
299
|
+
tasks.append(asyncio.create_task(
|
|
300
|
+
tail_file(path, queue, parser, hostname),
|
|
301
|
+
name=f"tail_{name}",
|
|
302
|
+
))
|
|
303
|
+
print(f"[agent] Tailing {path} ({name})", file=sys.stderr)
|
|
304
|
+
|
|
305
|
+
if not tasks:
|
|
306
|
+
print("[agent] Warning: no log sources found", file=sys.stderr)
|
|
307
|
+
|
|
308
|
+
tasks.append(asyncio.create_task(ws_sender(cfg, queue), name="ws_sender"))
|
|
309
|
+
|
|
310
|
+
# Status printer
|
|
311
|
+
async def _status() -> None:
|
|
312
|
+
while True:
|
|
313
|
+
await asyncio.sleep(60)
|
|
314
|
+
print(
|
|
315
|
+
f"[agent] queue={queue.qsize} dropped={queue.dropped}",
|
|
316
|
+
file=sys.stderr,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
tasks.append(asyncio.create_task(_status(), name="status"))
|
|
320
|
+
|
|
321
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def main() -> None:
|
|
325
|
+
"""CLI entrypoint: python -m cnsl.agent"""
|
|
326
|
+
import argparse
|
|
327
|
+
|
|
328
|
+
ap = argparse.ArgumentParser(description="CNSL Log Forwarding Agent")
|
|
329
|
+
ap.add_argument("--config", metavar="FILE", help="Config file path")
|
|
330
|
+
ap.add_argument("--server", metavar="URL", help="CNSL WebSocket URL")
|
|
331
|
+
ap.add_argument("--token", metavar="TOKEN", help="JWT authentication token")
|
|
332
|
+
ap.add_argument("--hostname", metavar="NAME", help="Agent hostname label")
|
|
333
|
+
args = ap.parse_args()
|
|
334
|
+
|
|
335
|
+
cfg = load_agent_config(args.config)
|
|
336
|
+
if args.server:
|
|
337
|
+
cfg["server"] = args.server
|
|
338
|
+
if args.token:
|
|
339
|
+
cfg["token"] = args.token
|
|
340
|
+
if args.hostname:
|
|
341
|
+
cfg["hostname"] = args.hostname
|
|
342
|
+
|
|
343
|
+
if not cfg.get("token"):
|
|
344
|
+
print("ERROR: --token required", file=sys.stderr)
|
|
345
|
+
sys.exit(1)
|
|
346
|
+
|
|
347
|
+
print(f"[agent] Starting CNSL agent v1.9.0 on {cfg['hostname']}", file=sys.stderr)
|
|
348
|
+
asyncio.run(run_agent(cfg))
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
if __name__ == "__main__":
|
|
352
|
+
main()
|
cnsl/api.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cnsl/api.py — Lightweight REST API (optional, requires aiohttp).
|
|
3
|
+
|
|
4
|
+
Endpoints:
|
|
5
|
+
GET /health — liveness probe
|
|
6
|
+
GET /status — engine summary + tracked IPs
|
|
7
|
+
POST /block {"ip": ...} — manually block an IP
|
|
8
|
+
POST /unblock {"ip": ...} — manually remove a block
|
|
9
|
+
|
|
10
|
+
Enable with --api flag (or api.enabled=true in config).
|
|
11
|
+
Bind only to 127.0.0.1 by default — do NOT expose to the internet.
|
|
12
|
+
Add nginx/auth proxy in front if you need remote access.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import json
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from .detector import Detector
|
|
23
|
+
from .blocker import Blocker
|
|
24
|
+
from .logger import JsonLogger
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def start_api(
|
|
28
|
+
host: str,
|
|
29
|
+
port: int,
|
|
30
|
+
detector: "Detector",
|
|
31
|
+
blocker: "Blocker",
|
|
32
|
+
logger: "JsonLogger",
|
|
33
|
+
) -> None:
|
|
34
|
+
try:
|
|
35
|
+
from aiohttp import web # type: ignore
|
|
36
|
+
except ImportError:
|
|
37
|
+
await logger.log("api_error", {"error": "aiohttp not installed. Run: pip install aiohttp"})
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
router = web.RouteTableDef()
|
|
41
|
+
|
|
42
|
+
@router.get("/health")
|
|
43
|
+
async def health(request: web.Request) -> web.Response:
|
|
44
|
+
return web.json_response({"status": "ok"})
|
|
45
|
+
|
|
46
|
+
@router.get("/status")
|
|
47
|
+
async def status(request: web.Request) -> web.Response:
|
|
48
|
+
return web.json_response({
|
|
49
|
+
"tracked_ips": detector.get_stats(),
|
|
50
|
+
"active_blocks": [
|
|
51
|
+
{"ip": ip, "unblock_at": blocker.active_blocks[ip]}
|
|
52
|
+
for ip in blocker.active_blocks
|
|
53
|
+
],
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
@router.post("/block")
|
|
57
|
+
async def manual_block(request: web.Request) -> web.Response:
|
|
58
|
+
body = await request.json()
|
|
59
|
+
ip = body.get("ip", "").strip()
|
|
60
|
+
if not ip:
|
|
61
|
+
return web.json_response({"error": "ip required"}, status=400)
|
|
62
|
+
ok = await blocker.block_ip(ip, reason="manual")
|
|
63
|
+
await logger.log("api_manual_block", {"ip": ip, "ok": ok})
|
|
64
|
+
return web.json_response({"blocked": ok, "ip": ip})
|
|
65
|
+
|
|
66
|
+
@router.post("/unblock")
|
|
67
|
+
async def manual_unblock(request: web.Request) -> web.Response:
|
|
68
|
+
body = await request.json()
|
|
69
|
+
ip = body.get("ip", "").strip()
|
|
70
|
+
if not ip:
|
|
71
|
+
return web.json_response({"error": "ip required"}, status=400)
|
|
72
|
+
if ip not in blocker.active_blocks:
|
|
73
|
+
return web.json_response({"error": "not blocked", "ip": ip}, status=404)
|
|
74
|
+
await blocker._unblock_ip(ip)
|
|
75
|
+
await logger.log("api_manual_unblock", {"ip": ip})
|
|
76
|
+
return web.json_response({"unblocked": True, "ip": ip})
|
|
77
|
+
|
|
78
|
+
app = web.Application()
|
|
79
|
+
app.add_routes(router)
|
|
80
|
+
|
|
81
|
+
runner = web.AppRunner(app)
|
|
82
|
+
await runner.setup()
|
|
83
|
+
site = web.TCPSite(runner, host, port)
|
|
84
|
+
await site.start()
|
|
85
|
+
await logger.log("api_started", {"host": host, "port": port})
|
|
86
|
+
|
|
87
|
+
# Block forever (the task is cancelled on shutdown)
|
|
88
|
+
await asyncio.Event().wait()
|