-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathadminchat.py
More file actions
2306 lines (2000 loc) · 102 KB
/
Copy pathadminchat.py
File metadata and controls
2306 lines (2000 loc) · 102 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# adminchat.py - Authenticated DCC CHAT console for the operator.
"""The operator's console: transport, authentication, and the commands.
This file is the whole admin console. It authenticates a session, and it
defines and dispatches every command that session can run - including the
destructive ones (ban, unban, clearqueue, rehash, update). COMMANDS at the
bottom is the complete list.
It said the opposite until #231: "Phase 1: transport and authentication only.
The admin commands themselves are NOT here yet, deliberately." That was true
when the file was written and stopped being true when the dispatcher landed.
It is the first thing anyone reads here, and it claimed a narrow
security-review scope - "who may open a session, and what proves it" - for a
file that also holds every command worth reviewing carefully.
The authentication is still the part to read first, and it is described below.
But it is no longer the only thing here.
WHY DCC CHAT RATHER THAN A CHANNEL OR PM COMMAND
------------------------------------------------
is_admin() compares a nick against ADMIN_NICK, and on Undernet a nick is not
owned without services auth - anyone can take the admin nick while the operator
is offline and inherit every admin command, including !clearqueue.
The gate here is the operator's Undernet services login. When a user logs into X
and sets usermode +x, the server replaces their host with
"<account>.users.undernet.org". Only the server can issue that host, and only to
someone holding that account, so matching the host IS verifying the login - with
no password shared with this bot and nothing to steal from its config.
The socket is then the session. There is no token, no nick binding and no expiry
bookkeeping: it dies when the TCP connection dies. That is the part a PM-based
!auth command can never get right, because it always ends up trusting a nick
again once the password has been accepted.
WHERE THIS DELIBERATELY DIFFERS FROM iroffer
---------------------------------------------
iroffer screens only the remote IP at connect time; its hostmask test lives in
dcc_host_password() and runs together with the password. So iroffer answers a
stranger: it accepts, prints a banner naming its version, build, OS, feature list
and uptime, and prompts.
This one screens the host on the incoming CTCP, before replying at all. A stranger
gets no banner, no connection, no reply of any kind, and no way to learn whether
the mask was wrong. The cost of an unauthorised attempt is one regex. When the
bot listens instead of dialling, the listener takes a connection only from the
address the operator's CTCP advertised (#680) - a scanner that reaches the port
first is dropped without a word and the window stays open for the operator.
CONNECTION DIRECTION, AND THE INBOUND SURFACE
---------------------------------------------
The preferred path follows iroffer's non-passive form: the requesting client
listens and supplies its ip/port, and the bot connects OUT to it. That path
opens no listening port.
Both fallbacks do. _open_chat_listener() binds a port in the
DCC_PORT_START..DCC_PORT_END range when the bot cannot dial the operator's
client, and the passive form - the client offering port 0, meaning "you listen
instead" - is parsed and answered with an offer of our own. So this module is
not inbound-surface-free, and the host check is what stands in front of that
surface.
Two earlier claims here were wrong and are corrected rather than deleted,
because both were load-bearing for anyone deciding how much of this file needs
a security review: that no listening port is opened (#217), and that passive
DCC is not supported (#231). parse_offer() has handled port 0 since the
listen-mode work, and tests/test_adminchat.py drives it end to end.
"""
import binascii
import collections
import hashlib
import hmac
import ipaddress
import os
import re
import socket
import threading
import time
import defaults as config
import platform_compat
# --------------------------------------------------------------------------
# Tunables. Deliberately module constants rather than config entries: these are
# safety limits, not preferences, and an operator lowering them by accident
# would weaken the gate.
# --------------------------------------------------------------------------
CONNECT_TIMEOUT = 10.0 # dialling the operator's client
LISTEN_TIMEOUT = 60.0 # waiting for the operator to accept our offer back
AUTH_TIMEOUT = 60.0 # seconds to supply a password before the socket closes
# There is deliberately NO idle timeout once authenticated. Earlier versions
# closed a quiet console after 30 minutes, and an operator who leaves the
# window open to watch the feed found it gone when they looked. A console is
# a window the operator opened; it stays open until they close it, a second
# login takes it over (_promote), or the connection itself dies - which TCP
# keepalive on the socket notices within a couple of minutes.
MAX_PASSWORD_ATTEMPTS = 3
WRONG_PASSWORD_DELAY = 1.0 # slows scripted guessing without tying up the reader
BAD_IP_BLOCK_SECONDS = 900.0
OUTBOX_MAX = 500 # bounded: a stalled client drops lines, never grows
PBKDF2_ITERATIONS = 200_000
# --------------------------------------------------------------------------
# Module state. This module is deliberately absent from commands.py's
# CORE_MODULES: importlib.reload re-executes a module body, which would
# drop a live session's socket on the floor on every !rehash. That is not
# hypothetical - it is what used to happen to every runtime container in
# config.py until PRESERVE_RUNTIME was added.
# --------------------------------------------------------------------------
_session = None # the one authenticated session, or None
_pending = None # at most one connected-but-unauthenticated session
_listening = False # at most one passive listener WAITING to be dialled
_state_lock = threading.Lock()
_bad_ips = {} # ip -> [failure_count, blocked_until, last_failure]
_bad_lock = threading.Lock()
def forget_stale_failures(pool, now, window=None):
"""Drop the addresses in `pool` that failed fewer than
MAX_PASSWORD_ATTEMPTS times and not within `window` seconds (#677,
audit L13). Called under the pool's own lock; returns how many went.
An address with one or two failures never reached a block, so the
expiry in is_bad_ip() never deleted it, and it stayed for the life of
the process - on an internet-exposed bind, one entry per scanner that
ever tried, for ever. A failure older than the block window does not
count towards a block either: two typos a day apart are not an attack.
Blocked addresses are left to the expiry that already forgets them.
"""
window = BAD_IP_BLOCK_SECONDS if window is None else window
stale = [ip for ip, entry in pool.items()
if not entry[1] and now - (entry[2] if len(entry) > 2 else now) >= window]
for ip in stale:
del pool[ip]
return len(stale)
# ==========================================================================
# Hostmask matching
# ==========================================================================
def source_host(prefix_or_line):
"""The host half of an IRC prefix, lowercased, or None.
Accepts either a bare prefix ("nick!ident@host") or a whole raw line, so
callers do not have to slice it first.
"""
if not prefix_or_line:
return None
text = str(prefix_or_line)
if text.startswith(":"):
text = text[1:]
text = text.split(" ", 1)[0]
if "@" not in text:
return None
return text.rsplit("@", 1)[1].strip().lower() or None
def host_pattern_of(mask):
"""Reduce a configured mask to the HOST pattern it really means.
A mask may be written either as a bare host ("operator.users.undernet.org") or in
the familiar iroffer/IRC form ("*!*@operator.users.undernet.org"). Either way only
the part after the last "@" is used.
The nick and ident halves are discarded ON PURPOSE. In nick!ident@host the
ident is supplied by the client - anyone can set theirs to "operator" - so a
pattern that appears to constrain it grants no security while breaking the
moment the operator's client changes its ident setting. Only the host is
issued by the server.
"""
if not mask:
return None
text = str(mask).strip().lower()
if not text:
return None
if "@" in text:
text = text.rsplit("@", 1)[1].strip()
return text or None
def _compile(pattern):
"""Wildcard pattern to anchored regex, matching security.py's hard-ban idiom."""
return re.compile("^" + re.escape(pattern).replace(r"\*", ".*") + "$")
def admin_host_patterns():
"""Configured host patterns, ignoring blanks. Empty means the console is off."""
raw = getattr(config, "ADMIN_HOSTMASKS", None) or []
if isinstance(raw, str):
raw = [part for part in raw.split(",")]
patterns = []
for entry in raw:
pattern = host_pattern_of(entry)
if pattern and pattern not in patterns:
patterns.append(pattern)
return patterns
# Host suffixes a network hands out to EVERY logged-in user, one label per
# account in front: "<account>.users.undernet.org". A pattern whose
# wildcard stands where the account goes does not name one operator, it
# names the whole logged-in population of the network (#669, audit L5).
SHARED_ACCOUNT_HOST_SUFFIXES = (
"users.undernet.org",
"users.quakenet.org",
)
def broad_host_patterns():
"""The configured patterns that are accepted but name far more than one
operator, each with the reason, for a warning at boot and on rehash
(#669, audit L5).
is_admin_host() refuses only a pattern that reduces to nothing once
wildcards and separators are stripped. A wildcard domain such as
"*.example.org" is deliberately accepted - it names a real, legitimate
set of hosts, and the password behind the gate is the second factor.
But two shapes are almost always a misreading of the documented
"<account>.users.undernet.org": a wildcard where the account goes
("*.users.undernet.org" - every X-authenticated user of the network
reaches the password prompt, can hold the single pending session
against the real operator, and costs the bot a dial per CTCP), and a
literal part of one label ("*.org" - a top-level domain). Both still
match exactly as configured; this only says so out loud.
"""
broad = []
for pattern in admin_host_patterns():
literal = pattern
for separator in "*!@":
literal = literal.replace(separator, "")
labels = [label for label in literal.split(".") if label]
if not labels:
continue # refused outright by is_admin_host()
if "*" not in pattern:
continue # one host, spelled out
if len(labels) == 1:
broad.append((pattern, f"it names the whole top-level domain .{labels[0]}"))
continue
head, _dot, rest = pattern.partition(".")
if rest.lower() in SHARED_ACCOUNT_HOST_SUFFIXES and set(head) <= set("*"):
broad.append((pattern, f"every logged-in user of the network has a {rest} host; "
f"the account name goes where the * is"))
return broad
def report_broad_host_patterns(log=print):
"""Print one warning per broad pattern; returns how many there were."""
broad = broad_host_patterns()
for pattern, why in broad:
log(f"[ADMINCHAT] WARNING: ADMIN_HOSTMASKS entry {pattern!r} is very broad - {why}. "
f"Anyone matching it reaches the console's password prompt. If you meant "
f"your own services host, write it in full, e.g. 'operator.users.undernet.org'.")
return len(broad)
def is_admin_host(prefix_or_line):
"""True only when the line's HOST matches a configured admin pattern.
A pattern reducing to nothing once wildcards are stripped is refused: it
would admit every host on the network and make the whole gate decorative.
security.py refuses an all-wildcard hard ban for the mirror-image reason.
Strips "*!@." - the same four characters security.py's own hard-ban guard
strips, not just "*". A HOST cannot contain "!" or "@" (only a full
<nick>!<ident>@<host> hostmask can), so those two are no-ops here - but a
host is made of dot-separated labels, and "*.*" reduces to a lone "." under
a stars-only strip: truthy, so it passed and compiled to a pattern
matching essentially every real host. #218, found by the same audit that
caught the hard-ban version of this in #168.
"""
host = source_host(prefix_or_line)
if not host:
return False
for pattern in admin_host_patterns():
residue = pattern
for separator in "*!@.":
residue = residue.replace(separator, "")
if not residue:
print(f"[ADMINCHAT] Refusing dangerously broad ADMIN_HOSTMASKS entry: {pattern!r}")
continue
if _compile(pattern).match(host):
return True
return False
# ==========================================================================
# Password
# ==========================================================================
def make_password_hash(password, iterations=PBKDF2_ITERATIONS):
"""Build the value to paste into admin_config.ADMIN_PASSWORD_HASH.
pbkdf2_hmac rather than scrypt: scrypt is the stronger primitive, but it
depends on the OpenSSL build Python was linked against and raises where that
is missing. pbkdf2 is always present in the standard library on every
platform, which matters for a daemon that has to run on both Linux and
Windows. The threat model tolerates it - an attacker must already hold the
operator's Undernet services account before the password is even reachable.
"""
salt = os.urandom(16)
digest = hashlib.pbkdf2_hmac("sha256", str(password).encode("utf-8"), salt, iterations)
return "pbkdf2_sha256${}${}${}".format(
iterations, binascii.hexlify(salt).decode(), binascii.hexlify(digest).decode())
def verify_password(stored, supplied):
"""Constant-time check of `supplied` against a stored pbkdf2 string.
Returns False rather than raising on a malformed or empty stored value: a
console with no password configured must refuse everyone, not admit them.
"""
if not stored or not supplied:
return False
try:
scheme, iterations, salt_hex, digest_hex = str(stored).split("$")
if scheme != "pbkdf2_sha256":
return False
expected = binascii.unhexlify(digest_hex)
actual = hashlib.pbkdf2_hmac(
"sha256", str(supplied).encode("utf-8"),
binascii.unhexlify(salt_hex), int(iterations))
except (ValueError, binascii.Error, TypeError):
return False
return hmac.compare_digest(expected, actual)
def password_is_configured():
return bool(getattr(config, "ADMIN_PASSWORD_HASH", ""))
# ==========================================================================
# Bad-IP tracking
# ==========================================================================
# Copied from iroffer's count_badip()/is_in_badip(). Counting attempts within one
# session is useless on its own, because an attacker simply reconnects; the count
# has to outlive the connection.
def note_bad_ip(ip):
if not ip:
return
with _bad_lock:
now = time.time()
forget_stale_failures(_bad_ips, now)
entry = _bad_ips.get(ip) or [0, 0.0, now]
entry[0] += 1
entry[2] = now
if entry[0] >= MAX_PASSWORD_ATTEMPTS:
entry[1] = now + BAD_IP_BLOCK_SECONDS
print(f"[ADMINCHAT] {ip} blocked for {int(BAD_IP_BLOCK_SECONDS)}s "
f"after {entry[0]} failed password attempt(s).")
_bad_ips[ip] = entry
def is_bad_ip(ip):
if not ip:
return False
with _bad_lock:
entry = _bad_ips.get(ip)
if not entry:
return False
if entry[1] and time.time() >= entry[1]:
del _bad_ips[ip] # block expired; forget it entirely so a typo is not permanent
return False
return bool(entry[1])
def clear_bad_ip(ip):
"""A successful login clears the record for that address."""
with _bad_lock:
_bad_ips.pop(ip, None)
# ==========================================================================
# Session
# ==========================================================================
_IRC_FORMATTING = re.compile("\\x03\\d{0,2}(?:,\\d{1,2})?|[\\x02\\x0f\\x16\\x1d\\x1f]")
def strip_irc_formatting(text):
"""Drop mIRC colour and attribute codes.
The debug channel's lines are built for a colour-capable client sitting in a
channel. A console is read as a log; the codes only get in the way, and some
clients render a DCC CHAT window without colour support at all.
"""
return _IRC_FORMATTING.sub("", str(text))
# ==========================================================================
# The structured feed (#550, step 2)
#
# A client that draws a window - dccore.mrc is the one this is for - wants
# fields, not prose. After `hello <client> <version>` a session gets every
# feed event as one line:
#
# DCCORE <TYPE> <fixed fields...> <free text>
#
# Space-separated positional tokens, the ONE free-text field last, so a
# mIRC script reads it as $N-. mIRC's whole toolkit is $1 $2 $N-, nicks never
# contain spaces, and every reserved separator (\x1f \x03 \x02 \x01) is a
# formatting or CTCP code already. Numbers are raw bytes and seconds; the
# client formats. Tabs and control characters in any field become spaces.
# ==========================================================================
PROTOCOL_MAJOR = 1
# The minor goes up every time a fixed field is INSERTED into a major-1 line
# (#639, audit M37). The channel field after <nick> went in without any
# number moving, on the reasoning that the protocol was unreleased - and an
# already-loaded older dccore.mrc then parsed the channel as the position,
# the slot, the byte count, with nothing anywhere saying why. HELLO now
# carries "major.minor": a script that knows the major but a different
# minor keeps parsing and warns that a field moved and which side to update;
# the pre-minor script's own `$2 != 1` refuses "1.1" outright and falls back
# to plain mode with "Update the script", which is the message it was
# missing. The number is a string on the wire, never arithmetic: "1.10"
# must not read as 1.1.
PROTOCOL_MINOR = 1
# The oldest dccore.mrc that reads this bot's lines right (#709, audit L45):
# the one that knows HELLO carries major.minor and that a channel field
# follows the nick. `hello <client> <version>` carries the script's own
# version and the bot used to log it and nothing more, so an operator who
# pulled the bot but not the script got every event line shifted by one
# field with nothing saying why. The script checks the bot's number; this
# is the bot checking the script's, and saying so in the window.
MIN_SCRIPT_VERSION = "1.1"
FEED_KINDS = ("REQUEST", "QUEUED", "SENDING", "RESUMED", "SENT", "FAIL", "SEARCH", "LISTFETCH")
def _version_tuple(text):
""""1.10" -> (1, 10); anything that is not digits and dots -> None."""
parts = str(text or "").strip().split(".")
if not parts or not all(part.isdigit() for part in parts):
return None
return tuple(int(part) for part in parts)
def script_is_too_old(version):
"""Whether a client that said `hello <client> <version>` predates
MIN_SCRIPT_VERSION - or gave no version this bot can read."""
ours = _version_tuple(MIN_SCRIPT_VERSION)
theirs = _version_tuple(version)
return theirs is None or theirs < ours
def _clean(value, token=False):
"""A field as it may appear on a structured line: control characters
(tabs, CR, LF, colour codes) become spaces; a TOKEN field additionally
has its spaces replaced, since it must stay one $N."""
text = "".join(" " if ord(ch) < 32 else ch for ch in str("" if value is None else value))
if token:
text = text.strip().replace(" ", "_") or "?"
return text.strip()
# A "::" standing on its own - bounded by whitespace or the ends of the name.
_MARKER_TOKEN = re.compile(r"(?<!\S)::(?!\S)")
def _name(value):
"""A filename field: the last field of most lines, and on FAIL the one
before the ` :: ` that separates it from the reason. A `::` of its own
inside the name used to be defused (` :: ` -> ` : : `) but one at the
END was not (#682, audit L18): "name ::" gave "name :: :: reason" and the
script read the reason as ":: reason". An empty name gave "0 0 ::
reason", and the script - which collapses runs of spaces - read the name
as ":: reason" and the reason as nothing. Every standalone `::` is
defused now, wherever it sits, and an empty name is "?"."""
text = _MARKER_TOKEN.sub(": :", _clean(value))
return text or "?"
def _num(value):
try:
return str(int(value))
except (TypeError, ValueError):
return "0"
def _secs(value):
try:
return f"{float(value):.1f}"
except (TypeError, ValueError):
return "0.0"
def _channel_token(value):
"""The channel field of an event line: the channel name, or "-" when the
event has none (a request by private message, a transfer whose request
no longer says where it came from). Always ONE token, since it sits
among the fixed fields, ahead of the free text."""
text = _clean(value, token=True)
return text if text[:1] in ("#", "&", "+", "!") else "-"
def structured_line(kind, fields):
"""Render one feed event as its DCCORE line. Every kind FEED_KINDS names
has a shape below; anything else is a LOG line carrying the category
and the prose, so no category is ever lost by the typing."""
f = fields or {}
nick = _clean(f.get("nick"), token=True)
chan = _channel_token(f.get("channel"))
name = _name(f.get("name"))
kind = str(kind or "").upper()
if kind == "REQUEST":
return f"DCCORE REQUEST {nick} {chan} {_clean(f.get('kind') or 'file', token=True)} {name}"
if kind == "QUEUED":
return f"DCCORE QUEUED {nick} {chan} {_num(f.get('pos'))} {_num(f.get('busy'))} {_num(f.get('slots'))} {name}"
if kind == "SENDING":
return f"DCCORE SENDING {nick} {chan} {_num(f.get('slot'))} {_num(f.get('slots'))} {_num(f.get('bytes'))} {name}"
if kind == "RESUMED":
return f"DCCORE RESUMED {nick} {chan} {_num(f.get('at_bytes'))} {_num(f.get('total_bytes'))} {name}"
if kind == "SENT":
return (f"DCCORE SENT {nick} {chan} {_num(f.get('bytes'))} {_secs(f.get('seconds'))} "
f"{_num(f.get('bytes_per_s'))} {name}")
if kind == "FAIL":
return (f"DCCORE FAIL {nick} {chan} {_num(f.get('acked'))} {_num(f.get('total'))} {name} :: "
f"{_clean(f.get('reason'))}")
if kind == "SEARCH":
return f"DCCORE SEARCH {nick} {chan} {_num(f.get('results'))} {_clean(f.get('term'))}"
if kind == "LISTFETCH":
# A held bot list: asked for automatically, arrived, or unusable (#750).
# No channel: it is about a bot, not a person's request.
return (f"DCCORE LISTFETCH {_clean(f.get('bot'), token=True)} "
f"{_clean(f.get('action'), token=True)} {_clean(f.get('text'))}")
return f"DCCORE LOG {_clean(f.get('category') or kind or 'INFO', token=True)} {_clean(f.get('text'))}"
def hello_line():
import defaults as config
return (f"DCCORE HELLO {PROTOCOL_MAJOR}.{PROTOCOL_MINOR} "
f"{_clean(getattr(config, 'NICKNAME', ''), token=True)} "
f"{_clean(getattr(config, 'SCRIPT_VERSION', ''))}")
STATUS_INTERVAL = 30.0 # seconds between STATUS bursts to a structured session
STATUS_WAIT = 2.0 # seconds the figures get before a PING stands in for the burst (#614)
QUEUE_LINES_MAX = 20 # QUEUE rows per burst: the head of the queue, not all of it
FREEZE_TIMEOUT = 300.0 # dcc.py's five-minute countdown, for the QUEUE row's seconds-left
def status_lines(now=None):
"""The STATUS burst (#550, step 3): what the client's title bar and side
panel are drawn from, read from what the daemon already holds.
DCCORE STATUS <used> <slots> <qfiles> <qusers> <sent_today> <bytes_today> <bps_now> <record_bps>
<started_epoch> <failed> <searches> (the last three: since the bot started)
DCCORE SLOT <nick> <sent> <total> <bps> <name> one per active transfer
DCCORE QUEUE <pos> <nick> <files> <frozen_secs_left> one per queued user, first 20
Today's figures are the ROLLED ones (db.load_advanced_stats_rolled), for
the reason announce.py gives: the daemon rotates the day only when a
transfer completes, so the raw row still shows yesterday under Today
on a quiet morning. Every figure failing to load reads 0 rather than
taking the burst down: a title bar with a wrong number beats no title
bar, and the log line says what could not be read.
"""
import time as _time
now = _time.time() if now is None else now
transfers = [tx for tx in list(getattr(config, "active_transfers", []) or []) if isinstance(tx, dict)]
queue = {k: v for k, v in dict(getattr(config, "dcc_queue", {}) or {}).items() if v}
frozen = dict(getattr(config, "frozen_queues", {}) or {})
slots = getattr(config, "MAX_DCC_SLOTS", 0)
sent_today = bytes_today = record = bps_now = 0
try:
import db
import stats_mgr
stats = db.load_advanced_stats_rolled()
if isinstance(stats, list) and len(stats) > 6:
sent_today, bytes_today = int(stats[4] or 0), int(stats[5] or 0)
record = int(db.get_speed_record() or 0)
bps_now = int(stats_mgr.live_speed() or 0)
except Exception as err:
print(f"[ADMINCHAT] Status figures unavailable: {err}")
# Since the bot started, not since this client connected (#754): the start
# as an epoch (now minus the uptime), and the failures and searches the
# daemon itself has counted. Appended at the end of the line, which is
# what makes it a minor addition - an older script reads $1-$8 and stops.
started = failed = searches = 0
try:
import runtime
import stats_mgr as _stats
started = int(now - _stats.get_uptime_seconds())
failed = int(runtime.feed_counts.get("FAIL", 0))
searches = int(runtime.feed_counts.get("SEARCH", 0))
except Exception as err:
print(f"[ADMINCHAT] Start figures unavailable: {err}")
lines = [f"DCCORE STATUS {len(transfers)} {_num(slots)} "
f"{sum(len(rows) for rows in queue.values())} {len(queue)} "
f"{sent_today} {bytes_today} {bps_now} {record} "
f"{started} {failed} {searches}"]
for tx in transfers:
sent = int(tx.get("bytes_sent") or 0)
started = float(tx.get("started_at") or 0)
# The speed is what THIS connection has moved, not what the receiver
# holds: a resumed send starts with bytes_sent already at the resume
# point, and dividing all of it by the seconds since it restarted
# showed 108 MB/s for a link doing 6 (#746).
moved = max(0, sent - int(tx.get("resume_offset") or 0))
bps = int(moved / (now - started)) if started and now > started + 0.5 else 0
lines.append(f"DCCORE SLOT {_clean(tx.get('user'), token=True)} {sent} "
f"{_num(tx.get('size'))} {bps} {_clean(tx.get('file'))}")
# In the queue's own order, which is the order dcc.check_queue_and_send()
# walks it (dict insertion order: first request first, kept across a
# save/load). Sorted by nick, the <pos> was an alphabetical rank shown as
# a position, and with more than 20 waiting the user actually next in
# line could fall off the burst altogether (#612).
for pos, user in enumerate(list(queue)[:QUEUE_LINES_MAX], start=1):
left = 0
if user.lower() in frozen: # both dicts key on the lowercased nick; be sure
left = max(0, int(FREEZE_TIMEOUT - (now - float(frozen[user.lower()] or 0))))
lines.append(f"DCCORE QUEUE {pos} {_clean(user, token=True)} {len(queue[user])} {left}")
return lines
def console_line(msg_text, category="INFO"):
"""One feed line as the DCC chat shows it.
A DCC CHAT window IS an IRC client, and it renders mIRC colour codes the
way a channel does - so with ADMIN_CHAT_COLOURS on (the default) the tag
carries the same label and colour the debug channel's block does
(announce.category_tag(), one table for both), in the operator's own
theme, and the text keeps whatever bold a caller put round a nick. The
old "a console is read as a log, not rendered by an IRC client" was true
of the dashboard's Console page - whose sink keeps stripping - and false
of this one. Off gives the plain `[TAG] text` a client that does not
render codes wants. #550, step 1.
"""
import announce
import theme
if not bool(getattr(config, "ADMIN_CHAT_COLOURS", True)):
return f"[{category}] {strip_irc_formatting(msg_text)}"
label, colour = announce.category_tag(category, theme.blocks())
return f"{colour}[{label}]{config.C_RESET} {msg_text}"
class Session:
"""One DCC CHAT connection. The socket IS the session.
Writes never happen on a caller's thread. Everything that wants to say
something appends to a bounded deque and a dedicated writer thread drains it.
That is not tidiness: send_debug() is called from the IRC read loop, and if a
log line could block on a stalled admin client - a minimised window, a sleeping
laptop, a half-open TCP connection - the daemon's network thread would freeze
and drop off the server. The same bounded hand-off announce.py already uses.
"""
def __init__(self, sock, peer_ip, nick, host):
self.sock = sock
self.peer_ip = peer_ip
self.nick = nick
self.host = host
self.authenticated = False
self.opened_at = time.time()
self.last_activity = time.time()
self.attempts = 0
self.closed = False
self.dropped = 0
# Structured mode (#550): set by the `hello` command, never before
# authentication. `client` is what the script called itself.
self.structured = False
self.client = ""
self._reported_dropped = 0
self._status_sent_at = 0.0
self._status_due = False # set by event_sink, acted on by the writer
self._status_job = None # (thread, lines) while a burst is being computed
# The DCCORE CHANNELS line this session was last sent (#371): sent
# again with a status burst when the bot's channels change, so a
# chat window's channel list does not go stale (#958 review).
self._chat_channels = None
self._outbox = collections.deque(maxlen=OUTBOX_MAX)
self._wake = threading.Event()
self._lock = threading.Lock()
self._writer = None
# -- output ------------------------------------------------------------
def send(self, text=""):
"""Queue one line. Never blocks, never raises, never touches the socket.
In structured mode every line the bot sends starts with DCCORE, so a
command reply - the fourteen handlers call this directly - is wrapped
as `DCCORE OUT <text>`; a line that already is a DCCORE line goes as
it is. The client routes OUT lines to wherever it shows replies and
can never mistake one for an event.
"""
if self.closed:
return
text = str(text)
if self.structured and not text.startswith("DCCORE "):
text = "DCCORE OUT " + text
if len(self._outbox) == self._outbox.maxlen:
self.dropped += 1
self._outbox.append(text)
self._wake.set()
def start_writer(self):
self._writer = threading.Thread(target=self._writer_loop, daemon=True)
self._writer.start()
def send_status(self):
"""One STATUS burst, now - ON THE WRITER THREAD ONLY.
status_lines() reads the live figures, and one of them
(stats_mgr.live_speed) takes dcc.queue_lock, a plain Lock. The
events that ask for a burst - SENDING above all - are emitted from
inside `with queue_lock:` in dcc.check_queue_and_send(), so computing
the burst on the emitting thread was that thread taking a lock it
already held: it froze holding queue_lock, every later request froze
behind it, the IRC loop stopped answering PING and the bot dropped
off the network (seen live, 2026-09-19, the first night with the
mIRC script connected). So event_sink only flags that a burst is
due, and the writer, which holds nothing, computes and sends it.
Computes it on a helper thread with a deadline, not inline (#614):
the burst is also the heartbeat, and a writer parked on queue_lock
(or a locked stats DB) for the script's 90 seconds sent nothing at
all - not the feed, not a LOG line - so the script called the link
dead, reconnected, and the new session's writer parked at the same
point: a login every ~100 s while the bot itself was fine. If the
figures are not in within STATUS_WAIT, `DCCORE PING` stands in for
the burst - any line resets the script's timer - and the helper is
left to finish; while it is still running no second one is started,
and its lines go out on the pass that finds them ready in time.
"""
if self.closed or not self.authenticated or not self.structured:
return
self._status_sent_at = time.time()
self._status_due = False
self._send_chat_channels_if_changed()
job = self._status_job
if job is None or not job[0].is_alive():
lines = []
def compute():
try:
lines.extend(status_lines())
except Exception as err:
print(f"[ADMINCHAT] Status burst failed: {err}")
job = (threading.Thread(target=compute, daemon=True), lines)
self._status_job = job
job[0].start()
job[0].join(STATUS_WAIT)
if job[0].is_alive():
self.send("DCCORE PING")
return
self._status_job = None
for line in job[1]:
self.send(line)
def _send_chat_channels_if_changed(self):
"""DCCORE CHANNELS again when the bot's channels have changed since
this session was told (#371). A chat window reads it to know which
channels the bot relays; stale, it would hide a raw NOTICE for a
channel the bot has left. At most one status interval late. Only to
a session told once already, at `hello` - anything else never asked
for the chat's channels."""
try:
import serverschat
line = serverschat.channels_line()
except Exception:
return
if self._chat_channels is not None and line != self._chat_channels:
self._chat_channels = line
self.send(line)
def request_status(self):
"""Ask the writer for a burst on its next pass. Safe from any thread,
under any lock: it touches no figure and takes no lock."""
self._status_due = True
self._wake.set()
def _writer_loop(self):
while not self.closed:
# A burst an event asked for goes out ahead of whatever is queued
# behind it: the title bar should not wait for the backlog, and
# a burst is a few lines. The timer's own burst, below, still
# fills silence only.
if self._status_due and self.structured and self.authenticated:
self.send_status()
if not self._outbox:
# The STATUS timer rides on the writer's own half-second
# wake rather than a thread of its own: one thread per
# session was the design, and a burst every STATUS_INTERVAL
# is also the heartbeat a client uses to tell a quiet link
# from a dead one. It fills silence only - a client that is
# behind is receiving lines already, and a burst on top of a
# backlog would only push more of them off the outbox.
if (self.structured and self.authenticated
and time.time() - self._status_sent_at >= STATUS_INTERVAL):
self.send_status()
continue
self._wake.wait(0.5)
self._wake.clear()
continue
try:
line = self._outbox.popleft()
except IndexError:
continue
# A slow client loses lines rather than stalling the daemon; in
# structured mode it is told how many, on the next line that does
# get through, so the window can say so instead of silently
# missing them.
if self.structured and self.dropped > self._reported_dropped:
lost = self.dropped - self._reported_dropped
self._reported_dropped = self.dropped
line = f"DCCORE DROPPED {lost}\n" + line
# DCC CHAT is line-oriented and terminated with \n. mIRC accepts \r\n
# too, but a bare \n is what every other client expects.
payload = (line + "\n").encode("utf-8", "replace")
try:
with self._lock:
self.sock.sendall(payload)
except (OSError, socket.timeout) as err:
print(f"[ADMINCHAT] Write to {self.nick} failed ({err}); closing session.")
self.close(announce_text=None)
return
def debug_sink(self, msg_text, category="INFO"):
"""Target for announce.send_debug's fan-out.
A pure append, like send() itself. This runs on whatever thread called
send_debug - including the IRC read loop - so it must never touch the
socket or wait for anything.
"""
if self.closed or not self.authenticated:
return
if self.structured:
# The feed kinds arrive with their fields through event_sink;
# sending the prose too would show every event twice.
if str(category).upper() in FEED_KINDS:
return
self.send(structured_line("LOG", {"category": category,
"text": strip_irc_formatting(msg_text)}))
return
self.send(console_line(msg_text, category))
def event_sink(self, kind, fields, text):
"""Target for announce.feed_event's fan-out: the fields of one feed
event. Only a structured session draws on it; a plain one has the
prose from debug_sink already."""
if self.closed or not self.authenticated or not self.structured:
return
# `text` is the event's prose, handed to the sink beside the fields.
# The kinds that carry their own fields (REQUEST, SENT, ...) never
# read it; LISTFETCH's whole payload is that sentence, and it used to
# be looked for in the fields, where it was not - the window drew a
# bare "[LISTS]" tag (#750).
self.send(structured_line(kind, {"text": text, **fields}))
# A slot or a queue just changed; the title bar should not wait for
# the timer to say so. Flagged, not computed: this runs on the
# emitting thread, which may hold queue_lock - see send_status().
if str(kind).upper() in ("SENDING", "SENT", "FAIL", "QUEUED", "RESUMED"):
self.request_status()
# -- lifecycle ---------------------------------------------------------
def close(self, announce_text="Session closed."):
if self.closed:
return
try:
import announce
announce.remove_debug_sink(self.debug_sink)
announce.remove_event_sink(self.event_sink)
except Exception:
pass
if announce_text:
# Written inline rather than queued: the writer thread is about to
# stop, so a queued goodbye would never leave the building.
# Wrapped as send() wraps (#679, audit L15): "Goodbye." and "Line
# too long." went out bare on a structured session, the two lines
# that broke "every line starts with DCCORE". A line that already
# is one (DCCORE TAKEN) goes as it is.
if self.structured and not announce_text.startswith("DCCORE "):
announce_text = "DCCORE OUT " + announce_text
try:
with self._lock:
self.sock.sendall((announce_text + "\n").encode("utf-8", "replace"))
except OSError:
pass
self.closed = True
self._wake.set()
try:
self.sock.shutdown(socket.SHUT_RDWR)
except OSError:
pass
try:
self.sock.close()
except OSError:
pass
def expired(self, now=None):
"""True when this session has outstayed its allowance.
Only an UNAUTHENTICATED session has one: sixty seconds to supply a
password. An authenticated console is never closed by a clock - see
the note beside AUTH_TIMEOUT.
"""
if self.authenticated:
return False
now = now if now is not None else time.time()
return (now - self.opened_at) > AUTH_TIMEOUT
# ==========================================================================
# Banner and command surface
# ==========================================================================
def banner_lines():
"""Modelled on iroffer's chat_banner(): welcome, build, then the prompt.
The uptime line iroffer prints is deliberately absent from the BANNER, but
the value behind it is live: _uptime_seconds() below wraps
stats_mgr.get_uptime_seconds(), and both `uptime` and `status` report it.
This used to say the function was "called from nowhere" and reset to zero on
every !rehash. Neither is true any more: it has two callers, and stats_mgr
guards start_time with a try/except NameError precisely so a reload leaves
the original value standing (#232).
"""
return [
"",
f"Welcome to {getattr(config, 'NICKNAME', None)}",
f"{getattr(config, 'SCRIPT_VERSION', '')} - {platform_compat.describe()}",
"",
]
def format_uptime(seconds):
"""iroffer's phrasing, because the banner is modelled on iroffer's."""
seconds = int(max(0, seconds))
days, rest = divmod(seconds, 86400)
hours, rest = divmod(rest, 3600)
minutes = rest // 60
parts = []
if days:
parts.append(f"{days} Day{'s' if days != 1 else ''}")
if hours or days:
parts.append(f"{hours} Hr{'s' if hours != 1 else ''}")
parts.append(f"{minutes} Min")
if len(parts) > 1:
return ", ".join(parts[:-1]) + " and " + parts[-1]
return parts[0]
def _uptime_seconds():
import stats_mgr
return stats_mgr.get_uptime_seconds()
# --------------------------------------------------------------------------
# Read-only commands. These build their own output, so they answer directly.
# --------------------------------------------------------------------------
def _cmd_version(session, args):
session.send(getattr(config, "SCRIPT_VERSION", ""))
session.send(platform_compat.describe())
def _cmd_checkversion(session, args):
"""Ask GitHub now whether a newer DCCore exists (#572), whatever
CHECK_FOR_UPDATES says. On a thread, so the console is not held for the
request's timeout; the answer comes back into this session - success or
failure, never silence."""
import threading
def run():
import version_check
result = version_check.manual_check()
if result.get("cooldown"):
session.send(f"Checked moments ago - try again in {result['cooldown']}s. "
f"Version: {version_check.describe()}")
else:
session.send(f"Version check: {version_check.describe()}")
session.send("Asking GitHub for the latest release...")
threading.Thread(target=run, daemon=True).start()
def _checkupdates_reply(session, on):
"""The setting, said to whoever asked: the DCCORE line dccore.mrc reads
to a structured session, a sentence to a person - a plain DCC chat, or
the dashboard's Console (_WebConsoleSession.structured is False). A
person used to be shown the protocol line itself; `pair` made the same
choice for the same reason (#581)."""
if getattr(session, "structured", False):
return f"DCCORE CHECKUPDATES {'on' if on else 'off'}"
return (f"The daily update check is {'on' if on else 'off'}"
f"{'' if on else ' - checkversion still asks GitHub by hand'}.")
def _cmd_checkupdates(session, args):
"""Turn CHECK_FOR_UPDATES on or off, or report it with no argument.