danielostrow commited on
Commit
20b4565
·
verified ·
1 Parent(s): 4ab39cb

Upload folder using huggingface_hub

Browse files
Files changed (1) hide show
  1. app.py +118 -286
app.py CHANGED
@@ -1,5 +1,5 @@
1
  #!/usr/bin/env python3
2
- """C2Sentinel - HuggingFace Space Demo"""
3
 
4
  import gradio as gr
5
  import json
@@ -7,321 +7,153 @@ import re
7
  from datetime import datetime
8
  from huggingface_hub import hf_hub_download
9
 
10
- # Download and load model
11
  hf_hub_download(repo_id="danielostrow/c2sentinel", filename="c2sentinel.py", local_dir=".")
12
  hf_hub_download(repo_id="danielostrow/c2sentinel", filename="c2_sentinel.safetensors", local_dir=".")
13
  hf_hub_download(repo_id="danielostrow/c2sentinel", filename="c2_sentinel.json", local_dir=".")
14
  from c2sentinel import C2Sentinel
15
  sentinel = C2Sentinel.load('c2_sentinel')
16
 
17
- # Examples
18
  EXAMPLES = {
19
- "C2 Beacon (60s)": [
20
- {"timestamp": 1705600000+i*60, "dst_ip": "45.33.32.156", "dst_port": 443, "bytes_sent": 200, "bytes_recv": 500}
21
- for i in range(8)
22
- ],
23
- "Metasploit 4444": [
24
- {"timestamp": 1705600000+i*30, "dst_ip": "10.10.10.10", "dst_port": 4444, "bytes_sent": 150, "bytes_recv": 300}
25
- for i in range(6)
26
- ],
27
- "SSH Keepalive": [
28
- {"timestamp": 1705600000+i*30, "dst_ip": "192.168.1.10", "dst_port": 22, "bytes_sent": 48, "bytes_recv": 48}
29
- for i in range(6)
30
- ],
31
- "Web Traffic": [
32
- {"timestamp": 1705600000, "dst_ip": "93.184.216.34", "dst_port": 443, "bytes_sent": 500, "bytes_recv": 15000},
33
- {"timestamp": 1705600002, "dst_ip": "151.101.1.140", "dst_port": 443, "bytes_sent": 800, "bytes_recv": 45000},
34
- {"timestamp": 1705600010, "dst_ip": "172.217.14.206", "dst_port": 443, "bytes_sent": 300, "bytes_recv": 12000},
35
- {"timestamp": 1705600015, "dst_ip": "93.184.216.34", "dst_port": 443, "bytes_sent": 200, "bytes_recv": 8000},
36
- {"timestamp": 1705600025, "dst_ip": "151.101.1.140", "dst_port": 443, "bytes_sent": 150, "bytes_recv": 5000},
37
- ],
38
  }
39
 
40
 
41
- def parse_timestamp(ts_str):
42
- """Parse various timestamp formats to unix timestamp."""
43
- if not ts_str:
44
- return None
45
-
46
- # Already numeric
47
  try:
48
- ts = float(ts_str)
49
- if ts > 1e12: # milliseconds
50
- return ts / 1000
51
- return ts
52
- except:
53
- pass
54
-
55
- # Common date formats
56
- formats = [
57
- "%Y-%m-%dT%H:%M:%S.%fZ",
58
- "%Y-%m-%dT%H:%M:%SZ",
59
- "%Y-%m-%dT%H:%M:%S.%f",
60
- "%Y-%m-%dT%H:%M:%S",
61
- "%Y-%m-%d %H:%M:%S.%f",
62
- "%Y-%m-%d %H:%M:%S",
63
- "%b %d %H:%M:%S",
64
- "%b %d %H:%M:%S",
65
- "%d/%b/%Y:%H:%M:%S",
66
- ]
67
-
68
- for fmt in formats:
69
  try:
70
- dt = datetime.strptime(ts_str.strip(), fmt)
71
- if dt.year == 1900:
72
- dt = dt.replace(year=datetime.now().year)
73
  return dt.timestamp()
74
- except:
75
- continue
76
  return None
77
 
78
 
79
- def extract_ip_port(text):
80
- """Extract IP and port from various formats."""
81
- # IP:port format
82
- m = re.search(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d+)', text)
83
- if m:
84
- return m.group(1), int(m.group(2))
85
-
86
- # Just IP
87
- m = re.search(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})', text)
88
- if m:
89
- return m.group(1), None
90
-
91
- return None, None
92
-
93
-
94
  def parse_logs(content):
95
- """Parse multiple log formats into connection records."""
96
- connections = []
97
- lines = content.strip().split('\n')
98
-
99
- for line in lines:
100
  line = line.strip()
101
- if not line or line.startswith('#'):
102
- continue
103
-
104
- conn = None
105
 
106
- # 1. JSON format
107
  try:
108
- obj = json.loads(line)
109
- conn = {
110
- 'timestamp': parse_timestamp(obj.get('timestamp') or obj.get('ts') or obj.get('@timestamp') or obj.get('EventTime') or obj.get('time')),
111
- 'dst_ip': obj.get('dst_ip') or obj.get('dest_ip') or obj.get('id.resp_h') or obj.get('DestinationIP') or obj.get('dst') or obj.get('destination_ip'),
112
- 'dst_port': obj.get('dst_port') or obj.get('dest_port') or obj.get('id.resp_p') or obj.get('DestinationPort') or obj.get('dport'),
113
- 'bytes_sent': obj.get('bytes_sent') or obj.get('orig_bytes') or obj.get('SentBytes') or obj.get('out_bytes') or 0,
114
- 'bytes_recv': obj.get('bytes_recv') or obj.get('resp_bytes') or obj.get('ReceivedBytes') or obj.get('in_bytes') or 0,
115
  }
116
- except json.JSONDecodeError:
117
- pass
118
 
119
- # 2. Zeek/Bro conn.log (tab-separated)
120
- if not conn:
121
- parts = line.split('\t')
122
- if len(parts) >= 10:
123
  try:
124
- conn = {
125
- 'timestamp': parse_timestamp(parts[0]),
126
- 'dst_ip': parts[4] if parts[4] != '-' else None,
127
- 'dst_port': int(parts[5]) if parts[5] != '-' else None,
128
- 'bytes_sent': int(parts[9]) if len(parts) > 9 and parts[9] != '-' else 0,
129
- 'bytes_recv': int(parts[10]) if len(parts) > 10 and parts[10] != '-' else 0,
130
- }
131
- except:
132
- pass
133
-
134
- # 3. Syslog format (various)
135
- if not conn:
136
- # Standard syslog: "Mon DD HH:MM:SS host process: message"
137
- syslog_match = re.match(r'^(\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})\s+\S+\s+\S+:\s*(.*)$', line)
138
- if syslog_match:
139
- ts = parse_timestamp(syslog_match.group(1))
140
- msg = syslog_match.group(2)
141
- dst_ip, dst_port = extract_ip_port(msg)
142
-
143
- bytes_match = re.search(r'(\d+)\s*bytes?', msg, re.I)
144
- bytes_val = int(bytes_match.group(1)) if bytes_match else 100
145
-
146
- if dst_ip:
147
- conn = {
148
- 'timestamp': ts,
149
- 'dst_ip': dst_ip,
150
- 'dst_port': dst_port or 443,
151
- 'bytes_sent': bytes_val // 2,
152
- 'bytes_recv': bytes_val // 2,
153
- }
154
-
155
- # 4. Windows Event Log (XML-ish or key=value)
156
- if not conn:
157
- # Key=value format
158
- if '=' in line:
159
- kv = dict(re.findall(r'(\w+)=("[^"]*"|\S+)', line))
160
- for k in list(kv.keys()):
161
- kv[k] = kv[k].strip('"')
162
-
163
- dst_ip = kv.get('DestAddress') or kv.get('DestinationIp') or kv.get('dst') or kv.get('RemoteAddress')
164
- dst_port = kv.get('DestPort') or kv.get('DestinationPort') or kv.get('dport') or kv.get('RemotePort')
165
- ts = kv.get('TimeGenerated') or kv.get('EventTime') or kv.get('timestamp')
166
-
167
- if dst_ip:
168
- conn = {
169
- 'timestamp': parse_timestamp(ts) if ts else datetime.now().timestamp(),
170
- 'dst_ip': dst_ip,
171
- 'dst_port': int(dst_port) if dst_port and dst_port.isdigit() else 443,
172
- 'bytes_sent': int(kv.get('SentBytes', 100)),
173
- 'bytes_recv': int(kv.get('ReceivedBytes', 100)),
174
- }
175
-
176
- # 5. CSV format
177
- if not conn and ',' in line and not line.startswith('{'):
178
- parts = [p.strip().strip('"') for p in line.split(',')]
179
- if len(parts) >= 4:
180
- # Try to identify columns
181
- for i, p in enumerate(parts):
182
- ip, port = extract_ip_port(p)
183
- if ip:
184
- ts = None
185
- for j, q in enumerate(parts):
186
- ts = parse_timestamp(q)
187
- if ts:
188
- break
189
-
190
- conn = {
191
- 'timestamp': ts or datetime.now().timestamp(),
192
- 'dst_ip': ip,
193
- 'dst_port': port or 443,
194
- 'bytes_sent': 100,
195
- 'bytes_recv': 100,
196
- }
197
-
198
- # Look for bytes
199
- for p in parts:
200
- if p.isdigit() and int(p) > 0:
201
- conn['bytes_sent'] = int(p)
202
- break
203
- break
204
-
205
- # 6. Generic IP extraction as fallback
206
- if not conn:
207
- dst_ip, dst_port = extract_ip_port(line)
208
- if dst_ip:
209
- ts = parse_timestamp(re.search(r'[\d\-T:\.Z]+', line).group() if re.search(r'[\d\-T:\.Z]+', line) else None)
210
- conn = {
211
- 'timestamp': ts or datetime.now().timestamp(),
212
- 'dst_ip': dst_ip,
213
- 'dst_port': dst_port or 443,
214
- 'bytes_sent': 100,
215
- 'bytes_recv': 100,
216
- }
217
-
218
- # Validate and add
219
- if conn and conn.get('dst_ip') and conn.get('timestamp'):
220
- conn['dst_port'] = int(conn['dst_port'] or 443)
221
- conn['bytes_sent'] = int(conn['bytes_sent'] or 0)
222
- conn['bytes_recv'] = int(conn['bytes_recv'] or 0)
223
- connections.append(conn)
224
-
225
- return connections
226
-
227
-
228
- def analyze(input_text, file_obj, example, threshold, whitelist, blacklist):
229
- """Main analysis function."""
230
  try:
231
- # Reset lists
232
  sentinel.whitelist_ips = set()
233
  sentinel.blacklist_ips = set()
234
-
235
- if whitelist:
236
- sentinel.add_whitelist(ips=[ip.strip() for ip in whitelist.split(',') if ip.strip()])
237
- if blacklist:
238
- sentinel.add_blacklist(ips=[ip.strip() for ip in blacklist.split(',') if ip.strip()])
239
-
240
- # Get connections
241
- connections = []
242
-
243
- if file_obj:
244
- content = file_obj.decode('utf-8') if isinstance(file_obj, bytes) else open(file_obj, 'r').read()
245
- connections = parse_logs(content)
246
- if not connections:
247
- try:
248
- connections = json.loads(content)
249
- except:
250
- pass
251
-
252
- if not connections and example and example in EXAMPLES:
253
- connections = EXAMPLES[example]
254
-
255
- if not connections and input_text:
256
- connections = parse_logs(input_text)
257
- if not connections:
258
- try:
259
- connections = json.loads(input_text)
260
- except:
261
- pass
262
-
263
- if not connections:
264
- return "⚠️ No valid connections found. Check input format."
265
-
266
- if len(connections) < 3:
267
- return f"⚠️ Need 3+ connections (found {len(connections)})"
268
-
269
- # Analyze
270
- result = sentinel.analyze(connections, threshold=threshold)
271
-
272
- # Format output
273
- if result.is_c2:
274
- out = f"### 🚨 C2 DETECTED\n**Type:** {result.c2_type}\n\n"
275
- else:
276
- out = "### ✅ No C2 Detected\n\n"
277
-
278
- out += f"**Probability:** {result.c2_probability:.0%} · **Confidence:** {result.confidence:.0%} · **Connections:** {len(connections)}\n\n"
279
-
280
- if result.matched_legitimate_pattern:
281
- out += f"**Pattern:** {result.matched_legitimate_pattern}\n\n"
282
-
283
- if result.risk_factors:
284
- out += "**Risk:** " + " · ".join(result.risk_factors[:3]) + "\n\n"
285
-
286
- if result.recommendations and result.is_c2:
287
- out += "**Action:** " + result.recommendations[0] + "\n"
288
-
289
  return out
290
-
291
  except Exception as e:
292
- return f"⚠️ Error: {str(e)}"
293
-
294
 
295
- # UI
296
- with gr.Blocks(title="C2Sentinel", theme=gr.themes.Soft(), css="""
297
- .container { max-width: 900px; margin: auto; }
298
- footer { display: none !important; }
299
- """) as demo:
300
 
301
- gr.Markdown("# 🛡️ C2Sentinel\nDetect C2 beacon patterns in network logs")
 
302
 
303
  with gr.Row():
304
- with gr.Column(scale=3):
305
- file_input = gr.File(label="Upload Log File", file_types=[".json", ".log", ".txt", ".csv", ".evtx"], type="binary")
306
- text_input = gr.Textbox(label="Or Paste Log Data", lines=6, placeholder="JSON, syslog, Zeek, CSV, Windows events...")
307
- example_select = gr.Radio(choices=list(EXAMPLES.keys()), label="Or Use Example", value=None)
308
-
309
  with gr.Column(scale=2):
310
- threshold = gr.Slider(0.3, 0.8, 0.5, step=0.1, label="Sensitivity", info="Lower = more alerts")
311
- whitelist = gr.Textbox(label="Whitelist IPs", placeholder="8.8.8.8, 1.1.1.1", lines=1)
312
- blacklist = gr.Textbox(label="Blacklist IPs", placeholder="10.10.10.10", lines=1)
313
- btn = gr.Button("Analyze", variant="primary", size="lg")
314
-
315
- output = gr.Markdown()
316
-
317
- gr.Markdown("""
318
- ---
319
- **Formats:** JSON, Zeek conn.log, syslog, Graylog, Windows Event, CSV
320
- **Docs:** [Model](https://huggingface.co/danielostrow/c2sentinel) · [API](https://huggingface.co/danielostrow/c2sentinel/blob/main/API_REFERENCE.md) · [neuralintellect.com](https://neuralintellect.com)
321
- """)
322
-
323
- btn.click(analyze, [text_input, file_input, example_select, threshold, whitelist, blacklist], output)
324
- example_select.change(lambda x: "", [example_select], [text_input])
325
-
326
- if __name__ == "__main__":
327
- demo.launch(server_name="0.0.0.0", server_port=7860)
 
1
  #!/usr/bin/env python3
2
+ """C2Sentinel Demo"""
3
 
4
  import gradio as gr
5
  import json
 
7
  from datetime import datetime
8
  from huggingface_hub import hf_hub_download
9
 
10
+ # Load model
11
  hf_hub_download(repo_id="danielostrow/c2sentinel", filename="c2sentinel.py", local_dir=".")
12
  hf_hub_download(repo_id="danielostrow/c2sentinel", filename="c2_sentinel.safetensors", local_dir=".")
13
  hf_hub_download(repo_id="danielostrow/c2sentinel", filename="c2_sentinel.json", local_dir=".")
14
  from c2sentinel import C2Sentinel
15
  sentinel = C2Sentinel.load('c2_sentinel')
16
 
 
17
  EXAMPLES = {
18
+ "C2 Beacon": [{"timestamp": 1705600000+i*60, "dst_ip": "45.33.32.156", "dst_port": 443, "bytes_sent": 200, "bytes_recv": 500} for i in range(8)],
19
+ "Metasploit": [{"timestamp": 1705600000+i*30, "dst_ip": "10.10.10.10", "dst_port": 4444, "bytes_sent": 150, "bytes_recv": 300} for i in range(6)],
20
+ "SSH Keepalive": [{"timestamp": 1705600000+i*30, "dst_ip": "192.168.1.10", "dst_port": 22, "bytes_sent": 48, "bytes_recv": 48} for i in range(6)],
21
+ "Web Traffic": [{"timestamp": 1705600000+i*5, "dst_ip": f"93.184.{i}.34", "dst_port": 443, "bytes_sent": 500+i*100, "bytes_recv": 15000+i*5000} for i in range(5)],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  }
23
 
24
 
25
+ def parse_ts(s):
26
+ if not s: return None
 
 
 
 
27
  try:
28
+ t = float(s)
29
+ return t/1000 if t > 1e12 else t
30
+ except: pass
31
+ for f in ["%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", "%b %d %H:%M:%S"]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  try:
33
+ dt = datetime.strptime(str(s).strip(), f)
34
+ if dt.year == 1900: dt = dt.replace(year=2026)
 
35
  return dt.timestamp()
36
+ except: pass
 
37
  return None
38
 
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  def parse_logs(content):
41
+ conns = []
42
+ for line in content.strip().split('\n'):
 
 
 
43
  line = line.strip()
44
+ if not line or line.startswith('#'): continue
45
+ c = None
 
 
46
 
47
+ # JSON
48
  try:
49
+ o = json.loads(line)
50
+ c = {
51
+ 'timestamp': parse_ts(o.get('timestamp') or o.get('ts') or o.get('@timestamp') or o.get('time')),
52
+ 'dst_ip': o.get('dst_ip') or o.get('dest_ip') or o.get('id.resp_h') or o.get('DestinationIP') or o.get('dst'),
53
+ 'dst_port': o.get('dst_port') or o.get('dest_port') or o.get('id.resp_p') or o.get('DestinationPort') or o.get('dport'),
54
+ 'bytes_sent': o.get('bytes_sent') or o.get('orig_bytes') or o.get('SentBytes') or 100,
55
+ 'bytes_recv': o.get('bytes_recv') or o.get('resp_bytes') or o.get('ReceivedBytes') or 100,
56
  }
57
+ except: pass
 
58
 
59
+ # Zeek
60
+ if not c:
61
+ p = line.split('\t')
62
+ if len(p) >= 10:
63
  try:
64
+ c = {'timestamp': parse_ts(p[0]), 'dst_ip': p[4] if p[4]!='-' else None, 'dst_port': int(p[5]) if p[5]!='-' else 443,
65
+ 'bytes_sent': int(p[9]) if p[9]!='-' else 100, 'bytes_recv': int(p[10]) if len(p)>10 and p[10]!='-' else 100}
66
+ except: pass
67
+
68
+ # Syslog
69
+ if not c:
70
+ m = re.match(r'^(\w{3}\s+\d+\s+\d+:\d+:\d+)\s+\S+\s+\S+:\s*(.*)$', line)
71
+ if m:
72
+ ip = re.search(r'(\d+\.\d+\.\d+\.\d+)', m.group(2))
73
+ if ip:
74
+ c = {'timestamp': parse_ts(m.group(1)), 'dst_ip': ip.group(1), 'dst_port': 443, 'bytes_sent': 100, 'bytes_recv': 100}
75
+
76
+ # Key=value
77
+ if not c and '=' in line:
78
+ kv = dict(re.findall(r'(\w+)=(\S+)', line))
79
+ ip = kv.get('DestAddress') or kv.get('DestinationIp') or kv.get('dst') or kv.get('RemoteAddress')
80
+ if ip:
81
+ c = {'timestamp': parse_ts(kv.get('TimeGenerated') or kv.get('EventTime')) or datetime.now().timestamp(),
82
+ 'dst_ip': ip, 'dst_port': int(kv.get('DestPort', 443)), 'bytes_sent': 100, 'bytes_recv': 100}
83
+
84
+ # CSV
85
+ if not c and ',' in line:
86
+ for p in line.split(','):
87
+ ip = re.search(r'(\d+\.\d+\.\d+\.\d+)', p)
88
+ if ip:
89
+ c = {'timestamp': datetime.now().timestamp(), 'dst_ip': ip.group(1), 'dst_port': 443, 'bytes_sent': 100, 'bytes_recv': 100}
90
+ break
91
+
92
+ # Fallback
93
+ if not c:
94
+ ip = re.search(r'(\d+\.\d+\.\d+\.\d+)', line)
95
+ if ip:
96
+ c = {'timestamp': datetime.now().timestamp(), 'dst_ip': ip.group(1), 'dst_port': 443, 'bytes_sent': 100, 'bytes_recv': 100}
97
+
98
+ if c and c.get('dst_ip') and c.get('timestamp'):
99
+ c['dst_port'] = int(c.get('dst_port') or 443)
100
+ c['bytes_sent'] = int(c.get('bytes_sent') or 100)
101
+ c['bytes_recv'] = int(c.get('bytes_recv') or 100)
102
+ conns.append(c)
103
+ return conns
104
+
105
+
106
+ def analyze(text, file, example, thresh, wl, bl):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  try:
 
108
  sentinel.whitelist_ips = set()
109
  sentinel.blacklist_ips = set()
110
+ if wl: sentinel.add_whitelist(ips=[x.strip() for x in wl.split(',') if x.strip()])
111
+ if bl: sentinel.add_blacklist(ips=[x.strip() for x in bl.split(',') if x.strip()])
112
+
113
+ conns = []
114
+ if file:
115
+ content = file.decode('utf-8') if isinstance(file, bytes) else open(file).read()
116
+ conns = parse_logs(content)
117
+ if not conns:
118
+ try: conns = json.loads(content)
119
+ except: pass
120
+ if not conns and example in EXAMPLES:
121
+ conns = EXAMPLES[example]
122
+ if not conns and text:
123
+ conns = parse_logs(text)
124
+ if not conns:
125
+ try: conns = json.loads(text)
126
+ except: pass
127
+
128
+ if not conns: return "No valid connections found"
129
+ if len(conns) < 3: return f"Need 3+ connections (found {len(conns)})"
130
+
131
+ r = sentinel.analyze(conns, threshold=thresh)
132
+
133
+ out = f"### {'C2 DETECTED: '+r.c2_type if r.is_c2 else 'No C2 Detected'}\n\n"
134
+ out += f"**Prob:** {r.c2_probability:.0%} | **Conf:** {r.confidence:.0%} | **N:** {len(conns)}\n\n"
135
+ if r.matched_legitimate_pattern: out += f"**Pattern:** {r.matched_legitimate_pattern}\n"
136
+ if r.risk_factors: out += "**Risk:** " + ", ".join(r.risk_factors[:3]) + "\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  return out
 
138
  except Exception as e:
139
+ return f"Error: {e}"
 
140
 
 
 
 
 
 
141
 
142
+ with gr.Blocks(title="C2Sentinel") as demo:
143
+ gr.Markdown("## C2Sentinel - Beacon Detection\n[Docs](https://huggingface.co/danielostrow/c2sentinel)")
144
 
145
  with gr.Row():
 
 
 
 
 
146
  with gr.Column(scale=2):
147
+ file_in = gr.File(label="Log File", file_types=[".json",".log",".txt",".csv"], type="binary")
148
+ text_in = gr.Textbox(label="Or Paste Logs", lines=5)
149
+ ex_in = gr.Dropdown(choices=[""] + list(EXAMPLES.keys()), value="", label="Example")
150
+ with gr.Column(scale=1):
151
+ thresh = gr.Slider(0.3, 0.8, 0.5, label="Threshold")
152
+ wl = gr.Textbox(label="Whitelist", placeholder="8.8.8.8")
153
+ bl = gr.Textbox(label="Blacklist", placeholder="10.10.10.10")
154
+ btn = gr.Button("Analyze", variant="primary")
155
+
156
+ out = gr.Markdown()
157
+ btn.click(analyze, [text_in, file_in, ex_in, thresh, wl, bl], out)
158
+
159
+ demo.launch(server_name="0.0.0.0", server_port=7860)