socksscope 0.2.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.
socksscope.py ADDED
@@ -0,0 +1,1230 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ socksscope - a SOCKS5 front-end that lets you manage your traffic and keep it
4
+ inside your engagement scope. Can wrap an existing SOCKS5 port or act independently.
5
+ """
6
+
7
+ import argparse
8
+ import asyncio
9
+ import contextlib
10
+ import enum
11
+ import hmac
12
+ import ipaddress
13
+ import logging
14
+ import re
15
+ import socket
16
+ import struct
17
+ import sys
18
+ import time
19
+ from pathlib import Path
20
+
21
+ import dns.asyncquery, dns.message, dns.rcode, dns.rdatatype
22
+
23
+ __version__ = "0.2.0"
24
+
25
+
26
+ LISTEN = ("127.0.0.1", 1081) # the SOCKS5 we open
27
+ LISTEN_AUTH = (None, None) # (user, pass) bytes we demand from our own clients
28
+ UPSTREAM = ("127.0.0.1", 1080) # SOCKS5 proxy to relay through, None = connect out ourselves
29
+ UPSTREAM_AUTH = (None, None) # (user, pass) for the upstream, if it wants them
30
+ DNS_SIDE = "upstream" # which side of the tunnel resolves, 'upstream' or 'local'
31
+ DNS_SERVER = None # (address, port) we query ourselves, None = that side resolves
32
+ DNS_TRANSPORT = None # 'tcp' or 'udp' towards DNS_SERVER
33
+ HOSTS = {} # static domain name -> [address, ...] mappings
34
+ RESOLVE_RULES = False # turn the domain name rules into address rules too
35
+ RESOLVE_RULES_EVERY = 300 # seconds between re-resolving rules, 0 = only once
36
+ RATE_PER_CONN = 0 # bytes/s per connection, 0 = unlimited
37
+ QUEUE_TIMEOUT = 60 # seconds a connection waits for a free slot
38
+
39
+ TTL_MIN, TTL_MAX = 30, 3600 # clamp cached DNS TTLs into this range
40
+ NEG_TTL = 30 # how long a "no such record" is remembered
41
+ CONNECT_TIMEOUT = 10 # seconds for a connect or a lookup
42
+ HANDSHAKE_TIMEOUT = 10 # seconds a client gets to send its handshake
43
+ HALF_OPEN_TIMEOUT = 60 # seconds a connection is held on after one side saw EOF
44
+ HOST_QUIET = 10 # seconds without a connection to a host before its summary is printed
45
+
46
+ _RULESET = None # the Ruleset built from --allow/--block
47
+ _RATE = None # global TokenBucket shared by all connections
48
+ _SLOTS = None # asyncio.Semaphore for --max-conns
49
+ _running = set() # strong refs to the live handle() connection tasks
50
+ _resolved_log = None # last --resolve-rules summary, to only log changes
51
+ _cache: dict[str, tuple[float, list[str]]] = {} # our dns cache
52
+ _locks: dict[str, asyncio.Lock] = {} # one lock per domain name, so requests
53
+ # arriving together share a lookup
54
+ _active: dict[str, dict] = {} # hosts being connected to, with the summary each one
55
+ # will print once it goes quiet
56
+ _session = {"connections": 0, "denied": 0, "sent": 0, "received": 0, "started": 0.0}
57
+
58
+ LOG = logging.getLogger("socksscope")
59
+
60
+ class _Formatter(logging.Formatter):
61
+ def format(self, record):
62
+ mark = f"{record.levelname.lower()}: " if record.levelno != logging.INFO else ""
63
+ return f"{self.formatTime(record, '%H:%M:%S')} {mark}{record.getMessage()}"
64
+
65
+ def setup_logging(verbose, quiet):
66
+ handler = logging.StreamHandler()
67
+ handler.setFormatter(_Formatter())
68
+ LOG.addHandler(handler)
69
+ LOG.setLevel(logging.WARNING if quiet else logging.DEBUG if verbose else logging.INFO)
70
+
71
+ def log_event(verb, message, peer=None):
72
+ if peer and LOG.isEnabledFor(logging.DEBUG): message += f" [{peer}]"
73
+ LOG.info(f"{verb:<8}{message}")
74
+
75
+ def is_ip(s):
76
+ try: ipaddress.ip_address(s)
77
+ except ValueError: return False
78
+ return True
79
+
80
+
81
+ # ── Ruleset ─────────────────────────────────────────────────────────────
82
+ # Handle the rules specified at startup and apply them during runtime
83
+
84
+ class Denied(Exception):
85
+ """Refused by the ruleset"""
86
+
87
+ class Verdict(enum.IntEnum):
88
+ ALLOW = 0
89
+ BLOCK = 1
90
+ def __str__(self): return self.name.lower()
91
+ def __invert__(self): return Verdict(1 - self)
92
+
93
+ class Rule:
94
+ def __init__(self, type, value, rank, verdict, text):
95
+ # type is 'name', 'ip' or 'port'
96
+ # rank decides how specific a rule is and only rules of the same
97
+ # type are compared so the rank does not need to be consistent between types
98
+ self.type, self.value, self.rank, self.verdict = type, value, rank, verdict
99
+ self.text = f"{verdict} {text}"
100
+
101
+ def matches(self, candidate):
102
+ if self.type == "ip":
103
+ return candidate.version == self.value.version and candidate in self.value
104
+ if self.type == "port":
105
+ return self.value[0] <= candidate <= self.value[1]
106
+ if self.type == "name":
107
+ name, subdomains_only = self.value
108
+ # '*' leaves no name behind, so it covers every domain name there is
109
+ if not name: return True
110
+ return candidate.endswith("." + name) or (not subdomains_only and candidate == name)
111
+ raise RuntimeError(f"{self.type} not as expected")
112
+
113
+ def __str__(self):
114
+ return self.text
115
+
116
+ def parse_port_rule(text, verdict):
117
+ # ':443' or ':8000-8100', both ends included
118
+ low, _, high = text[1:].partition("-")
119
+ low, high = int(low), int(high or low)
120
+ if not 0 < low <= high <= 65535:
121
+ raise ValueError(f"'{text}' is not a port range from low to high within 1-65535")
122
+ # rank: how few ports it covers, so :445 outranks :1-1024
123
+ return [Rule("port", (low, high), -(high - low + 1), verdict, text)]
124
+
125
+ def parse_ip_rule(text, verdict, label=None):
126
+ # '10.0.0.5', '10.0.0.0/8' or a '10.0.0.1-10.0.0.50' range, both ends included
127
+ if "-" in text:
128
+ first, last = (ipaddress.ip_address(part) for part in text.split("-", 1))
129
+ if first.version != last.version:
130
+ raise ValueError(f"'{text}' is a nonsensical range")
131
+ # a range rarely lines up with one prefix, so it becomes several rules
132
+ nets = list(ipaddress.summarize_address_range(first, last))
133
+ else:
134
+ nets = [ipaddress.ip_network(text, strict=False)]
135
+ # rank: how few addresses it covers, counted over the whole range, otherwise
136
+ # the pieces of one range would outrank each other
137
+ rank = -sum(net.num_addresses for net in nets)
138
+ return [Rule("ip", net, rank, verdict, label or text) for net in nets]
139
+
140
+ _DNS_LABEL = re.compile(r"[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?\Z")
141
+
142
+ def parse_name_rule(text, verdict):
143
+ # 'corp.local', 'sub.corp.local', '*.corp.local' and bare '*'
144
+ # '--block *' refuses every domain name; '--allow *' is a noop
145
+ if text == "*": return [Rule("name", ("", True), (0, True), verdict, text)]
146
+ is_wildcard = text.startswith("*.")
147
+ name = text.removeprefix("*.").removesuffix(".").lower()
148
+ labels = name.split(".")
149
+ if name and all(label.isdigit() for label in labels):
150
+ LOG.warning(f"'{text}' looks like you intended an IP but it's not valid and parsed as a domain name")
151
+ if not name or len(name) > 253 or not all(_DNS_LABEL.match(label) for label in labels):
152
+ raise ValueError(f"'{text}' is not a domain name we can use for the ruleset")
153
+ # rank: deeper subdomains win so vpn.corp.local outranks corp.local
154
+ return [Rule("name", (name, is_wildcard), (name.count(".") + 1, is_wildcard), verdict, text)]
155
+
156
+ def parse_rule(text, verdict):
157
+ # pick the type from the shape of the text and let that parser build the rules
158
+ if not text: raise ValueError("empty rule can't be processed")
159
+ # IP ranges and single IPs/Subnets first (IPv6 may look like a Port)
160
+ if "-" in text and all(is_ip(part) for part in text.split("-", 1)):
161
+ return parse_ip_rule(text, verdict)
162
+ try: ipaddress.ip_network(text, strict=False)
163
+ except ValueError: pass
164
+ else: return parse_ip_rule(text, verdict)
165
+ # ':443' and ':1-1024' are ports
166
+ if text.startswith(":"):
167
+ return parse_port_rule(text, verdict)
168
+ if text.isdigit():
169
+ # a bare number is not an address and would silently become a domain name
170
+ raise ValueError(f"'{text}' looks like a port, write it as ':{text}'")
171
+ # Domain name
172
+ return parse_name_rule(text, verdict)
173
+
174
+ class Ruleset:
175
+ def __init__(self, allow, block):
176
+ # rules directly specified as an argument
177
+ self.rules = self._load(allow, Verdict.ALLOW) + self._load(block, Verdict.BLOCK)
178
+ # rules derived from --resolve-rules, kept per domain name so we can track previous addresses
179
+ self.resolved = {}
180
+ # when each of the --resolve-rules names was last confirmed (for the grace period)
181
+ self._resolved_at = {}
182
+ # all rules that must be honored (self.rules plus self.resolved)
183
+ self.active = []
184
+ # which of 'name', 'ip' and 'port' have an allow rule: up until the first
185
+ # one of a type, everything of that type is accepted
186
+ self.restricted = set()
187
+ self._index()
188
+
189
+ @staticmethod
190
+ def _load(entries, verdict):
191
+ # Each entry is either one rule or '@file'
192
+ rules = []
193
+ for entry in entries or []:
194
+ lines = Path(entry[1:]).read_text().splitlines() if entry.startswith("@") else [entry]
195
+ for line in lines:
196
+ line = line.split("#")[0].strip()
197
+ if line:
198
+ # '!' flips the rule
199
+ rules += parse_rule(line.lstrip("!"),
200
+ ~verdict if line.startswith("!") else verdict)
201
+ return rules
202
+
203
+ def _index(self):
204
+ self.active = self.rules + [rule for rules in self.resolved.values() for rule in rules]
205
+ self.restricted = {rule.type for rule in self.active if rule.verdict is Verdict.ALLOW}
206
+
207
+ def resolveable_rules(self):
208
+ # return all domain rules that can be resolved for --resolve-rules (no wildcards)
209
+ return [rule for rule in self.rules if rule.type == "name" and not rule.value[1]]
210
+
211
+ def wildcard_rules(self):
212
+ return [rule for rule in self.rules if rule.type == "name" and rule.value[1]]
213
+
214
+ def reindex_resolved_name(self, name, rules):
215
+ # replaces only what this domain name contributed, so the names that did
216
+ # resolve are updated and the ones that failed keep what they had
217
+ self.resolved[name] = rules
218
+ self._resolved_at[name] = time.monotonic()
219
+ self._index()
220
+
221
+ def unconfirmed_for(self, name):
222
+ # seconds since this domain name last got an answer of any kind
223
+ return time.monotonic() - self._resolved_at.get(name, time.monotonic())
224
+
225
+ _DEFAULT_ALLOWS = {"name": ("*",), "ip": ("0.0.0.0/0", "::/0"), "port": (":1-65535",)}
226
+ def default_allows(self):
227
+ return [text for type, texts in self._DEFAULT_ALLOWS.items()
228
+ if type not in self.restricted for text in texts]
229
+
230
+ def check_rules(self, type, candidate):
231
+ # return (allowed, rule) where rule is the most specific rule that allows/blocks the candidate
232
+ best = None
233
+ for rule in self.active:
234
+ if rule.type == type and rule.matches(candidate) and (
235
+ best is None or (rule.rank, rule.verdict) > (best.rank, best.verdict)):
236
+ best = rule
237
+ if best:
238
+ return best.verdict is Verdict.ALLOW, best
239
+ return type not in self.restricted, None
240
+
241
+
242
+ # ── SOCKS5 ──────────────────────────────────────────────────────────────
243
+ # Both directions: client and server at the same time, sharing the address field
244
+
245
+ class ProxyError(Exception):
246
+ """The upstream proxy answered, but would not open the connection"""
247
+
248
+ # reply codes (RFC 1928), named for the ones we send
249
+ OK, FAILED, NOT_ALLOWED, UNREACHABLE, BAD_COMMAND, BAD_ADDRESS = 0, 1, 2, 4, 7, 8
250
+ REPLY_TEXT = {1: "general failure", 2: "not allowed by ruleset", 3: "network unreachable",
251
+ 4: "host unreachable", 5: "connection refused", 6: "TTL expired",
252
+ 7: "command not supported", 8: "address type not supported"}
253
+
254
+ def pack_address(host):
255
+ # ATYP byte and the address (or domain name) after it
256
+ try: address = ipaddress.ip_address(host)
257
+ except ValueError: return b"\x03" + bytes([len(host)]) + host.encode()
258
+ return (b"\x01" if address.version == 4 else b"\x04") + address.packed
259
+
260
+ async def read_address(reader, atyp):
261
+ # address following an ATYP byte, or None if we do not know that type or the name will not decode
262
+ if atyp == 1: return str(ipaddress.IPv4Address(await reader.readexactly(4)))
263
+ if atyp == 4: return str(ipaddress.IPv6Address(await reader.readexactly(16)))
264
+ if atyp == 3:
265
+ name = await reader.readexactly((await reader.readexactly(1))[0])
266
+ try: return name.decode()
267
+ except UnicodeDecodeError: return None
268
+ return None
269
+
270
+ # answering our own clients
271
+ async def read_request(reader):
272
+ # request from a client
273
+ _ver, cmd, _rsv, atyp = await reader.readexactly(4)
274
+ host = await read_address(reader, atyp)
275
+ if host is None: return cmd, atyp, None, None
276
+ (port,) = struct.unpack("!H", await reader.readexactly(2))
277
+ return cmd, atyp, host, port
278
+
279
+ async def send_reply(writer, rep):
280
+ # answer to a client, we don't need to specify an IPv4 bound address
281
+ # because it only matters for BIND and UDP ASSOCIATE
282
+ writer.write(b"\x05" + bytes([rep]) + b"\x00\x01" + b"\x00" * 6)
283
+ await writer.drain()
284
+
285
+ async def authenticate_client(reader, writer, methods):
286
+ # no authentication required
287
+ if LISTEN_AUTH[0] is None:
288
+ writer.write(b"\x05\x00")
289
+ await writer.drain()
290
+ return True
291
+ # none of the offered methods will do
292
+ if 2 not in methods:
293
+ writer.write(b"\x05\xff")
294
+ await writer.drain()
295
+ return False
296
+ # authenticate
297
+ writer.write(b"\x05\x02")
298
+ await writer.drain()
299
+
300
+ version, length = await reader.readexactly(2)
301
+ user = await reader.readexactly(length)
302
+ (length,) = await reader.readexactly(1)
303
+ password = await reader.readexactly(length)
304
+ ok = (version == 1 and hmac.compare_digest(user, LISTEN_AUTH[0])
305
+ and hmac.compare_digest(password, LISTEN_AUTH[1]))
306
+ writer.write(b"\x01" + (b"\x00" if ok else b"\x01"))
307
+ await writer.drain()
308
+ return ok
309
+
310
+ async def read_handshake(reader, writer, peer):
311
+ # everything a client has to send before we act:
312
+ # greeting, (optional) authentication, a request
313
+ ver, nmethods = await reader.readexactly(2)
314
+ if ver != 5: return None
315
+ methods = await reader.readexactly(nmethods)
316
+ if not await authenticate_client(reader, writer, methods):
317
+ log_event("refused", f"{peer} (--listen-auth credentials missing or wrong)")
318
+ return None
319
+ return await read_request(reader)
320
+
321
+ # asking the upstream SOCKS5 server
322
+ async def authenticate_upstream(reader, writer):
323
+ # Username/password (RFC 1929) to a server
324
+ user, password = UPSTREAM_AUTH[0].encode(), UPSTREAM_AUTH[1].encode()
325
+ writer.write(b"\x01" + bytes([len(user)]) + user + bytes([len(password)]) + password)
326
+ await writer.drain()
327
+ _version, status = await reader.readexactly(2)
328
+ if status != 0:
329
+ raise ProxyError("upstream rejected the credentials")
330
+
331
+ async def connect_upstream(host, port):
332
+ # Connect to a socks5 server
333
+ reader, writer = await asyncio.open_connection(*UPSTREAM)
334
+ # only the handshake below decides this, and every way out of it that is not
335
+ # a finished handshake has to close the socket - a cancellation (our own
336
+ # connect_out() timeout) included, so this is not an 'except Exception'
337
+ connected = False
338
+ try:
339
+ # Greeting
340
+ methods = b"\x00\x02" if UPSTREAM_AUTH[0] is not None else b"\x00"
341
+ writer.write(b"\x05" + bytes([len(methods)]) + methods)
342
+ await writer.drain()
343
+ _version, method = await reader.readexactly(2)
344
+ # Authentication (if we are asked for one)
345
+ if method == 2: await authenticate_upstream(reader, writer)
346
+ elif method != 0:
347
+ raise ProxyError("upstream accepted none of the authentication methods we offered"
348
+ if method == 0xFF else f"upstream asked for method {method}")
349
+ # Connect
350
+ writer.write(b"\x05\x01\x00" + pack_address(host) + struct.pack("!H", port))
351
+ await writer.drain()
352
+ _version, rep, _rsv, atyp = await reader.readexactly(4)
353
+ if rep != OK:
354
+ raise ProxyError(f"upstream said {REPLY_TEXT.get(rep, rep)}")
355
+ # Handle response (drop it)
356
+ if await read_address(reader, atyp) is None:
357
+ raise ProxyError(f"upstream replied with address type {atyp}")
358
+ await reader.readexactly(2)
359
+ connected = True
360
+ return reader, writer
361
+ except asyncio.IncompleteReadError:
362
+ raise ProxyError("upstream closed the connection without replying") from None
363
+ finally:
364
+ # Ensure that we always close even when the connection was not established correctly
365
+ if not connected: writer.close()
366
+
367
+ async def connect_out(host, port, local=False):
368
+ # Open a connection to the target, through the upstream or local
369
+ if local or UPSTREAM is None: opening = asyncio.open_connection(host, port)
370
+ else: opening = connect_upstream(host, port)
371
+ try: return await asyncio.wait_for(opening, CONNECT_TIMEOUT)
372
+ # re-raise TimeoutError to give it a meaningful message
373
+ except asyncio.TimeoutError: raise TimeoutError(f"no answer within {CONNECT_TIMEOUT}s") from None
374
+
375
+
376
+ # ── Name resolution ─────────────────────────────────────────────────────
377
+ # Resolve domain names using the way specified on the commandline
378
+
379
+ class DNSError(Exception):
380
+ """The DNS server answered, but not with anything usable"""
381
+
382
+ class NXDomain(DNSError):
383
+ """The server said this domain name does not exist anywhere, so there is
384
+ no point asking it anything else about that domain name"""
385
+
386
+ def parse_host_entry(line):
387
+ # 'name=ADDRESS' or the /etc/hosts form 'ADDRESS name [name ...]'
388
+ if "=" in line:
389
+ name, _, address = line.partition("=")
390
+ address, names = address.strip(), [name.strip()]
391
+ else:
392
+ address, *names = line.split()
393
+ if not is_ip(address) or not names or not all(names):
394
+ raise ValueError(f"'{line}' is neither 'name=ADDRESS' nor 'ADDRESS name [name ...]'")
395
+ return address, names
396
+
397
+ def load_hosts(entries):
398
+ # Each entry is either one mapping or '@file'
399
+ for entry in entries or []:
400
+ lines = Path(entry[1:]).read_text().splitlines() if entry.startswith("@") else [entry]
401
+ for line in lines:
402
+ line = line.split("#")[0].strip()
403
+ if line:
404
+ address, names = parse_host_entry(line)
405
+ for name in names:
406
+ HOSTS.setdefault(name.lower(), []).append(address)
407
+
408
+ async def query(host, rdtype):
409
+ # one DNS query for one record type and return (addresses, ttl)
410
+ request = dns.message.make_query(host, rdtype)
411
+ if DNS_TRANSPORT == "udp":
412
+ reply = await dns.asyncquery.udp(request, DNS_SERVER[0], port=DNS_SERVER[1], timeout=CONNECT_TIMEOUT)
413
+ else:
414
+ # Handle DNS/TCP by hand to use our connection logic
415
+ wire = request.to_wire()
416
+ reader, writer = await connect_out(*DNS_SERVER, local=DNS_SIDE == "local")
417
+ try:
418
+ # send two byte length prefix + the query and receive the same as a response
419
+ writer.write(struct.pack("!H", len(wire)) + wire)
420
+ await writer.drain()
421
+ (length,) = struct.unpack("!H", await reader.readexactly(2))
422
+ reply = dns.message.from_wire(await reader.readexactly(length))
423
+ finally:
424
+ writer.close()
425
+
426
+ # Reject anything unusable
427
+ if not request.is_response(reply):
428
+ raise DNSError("reply does not match the query")
429
+ if reply.rcode() == dns.rcode.NXDOMAIN:
430
+ raise NXDomain(host)
431
+ if reply.rcode() != dns.rcode.NOERROR:
432
+ raise DNSError(f"server said {dns.rcode.to_text(reply.rcode())}")
433
+
434
+ # Follow any CNAME chain to the address records
435
+ chain = reply.resolve_chaining()
436
+ if chain.answer is None:
437
+ return [], chain.minimum_ttl
438
+ return [rdata.address for rdata in chain.answer], chain.minimum_ttl
439
+
440
+ async def resolve_dns(host):
441
+ # Cached A and AAAA lookup, returns (addresses, came from cache)
442
+ # Start with shortcut for cached entries
443
+ hit = _cache.get(host)
444
+ if hit and hit[0] > time.time():
445
+ return hit[1], True
446
+
447
+ lock = _locks.setdefault(host, asyncio.Lock())
448
+ # One lookup per domain name at a time to prevent sending multiple queries
449
+ # by checking the cache first
450
+ async with lock:
451
+ hit = _cache.get(host)
452
+ if hit and hit[0] > time.time():
453
+ return hit[1], True
454
+ # Ask A and AAAA at the same time
455
+ answers = await asyncio.gather(query(host, dns.rdatatype.A),
456
+ query(host, dns.rdatatype.AAAA),
457
+ return_exceptions=True)
458
+ addresses, ttls, failure = [], [], None
459
+ for answer in answers:
460
+ if isinstance(answer, NXDomain): continue
461
+ if isinstance(answer, BaseException): failure = failure or answer
462
+ else:
463
+ found, found_ttl = answer
464
+ addresses += found
465
+ if found: ttls.append(found_ttl)
466
+ # only raise on error if A and AAAA returned nothing
467
+ if not addresses and failure is not None: raise failure
468
+ # cache the addresses or cache the miss
469
+ ttl = max(TTL_MIN, min(min(ttls), TTL_MAX)) if addresses else NEG_TTL
470
+ _cache[host] = (time.time() + ttl, addresses)
471
+ return addresses, False
472
+
473
+ async def resolve_system(host):
474
+ # whatever the local machine itself would resolve (/etc/hosts and resolv.conf honored)
475
+ info = await asyncio.get_running_loop().getaddrinfo(host, None, type=socket.SOCK_STREAM)
476
+ # getaddrinfo answers once per socket type and protocol
477
+ # dict.fromkeys drops the repeats while keeping returned order
478
+ return list(dict.fromkeys(sockaddr[0] for *_, sockaddr in info))
479
+
480
+ async def resolve_target(host):
481
+ host = host.lower()
482
+ # Choose which resolver should be used
483
+ if host in HOSTS: addresses, source = HOSTS[host], "--hosts"
484
+ elif DNS_SERVER:
485
+ addresses, cached = await resolve_dns(host)
486
+ source = f"{'cached - ' if cached else ''}{DNS_SERVER[0]}:{DNS_SERVER[1]}"
487
+ elif DNS_SIDE == "local": addresses, source = await resolve_system(host), "system resolver"
488
+ # None of the above means we let the upstream handle it
489
+ else: return None
490
+ LOG.debug(f"resolved {host} -> {head_elements(addresses, 4) or 'nothing'} ({source})")
491
+ return addresses
492
+
493
+
494
+ # ── Traffic limits ──────────────────────────────────────────────────────
495
+ # Restrict speed and maximum connections
496
+
497
+ class TokenBucket:
498
+ # Handle rate limiting for --rate and --rate-per-conn
499
+ def __init__(self, rate):
500
+ # tokens are bytes and rate is bytes per second so the bucket holds
501
+ # one second of traffic (that much may burst before it starts pacing)
502
+ self.rate, self.tokens, self.stamp = rate, rate, time.monotonic()
503
+ self.lock = asyncio.Lock()
504
+
505
+ async def take(self, amount):
506
+ async with self.lock:
507
+ # refill for the time that passed rather than on a timer
508
+ now = time.monotonic()
509
+ self.tokens = min(self.rate, self.tokens + (now - self.stamp) * self.rate)
510
+ self.stamp = now
511
+ # take the requested amount, going negative if it is not there yet
512
+ self.tokens -= amount
513
+ if self.tokens < 0:
514
+ # if too much is taken sleep until enough time has passed to
515
+ # accommodate them
516
+ # intentionally sleeping while holding the lock, which keeps
517
+ # waiting connections in order instead of racing for tokens
518
+ # the longest sleep is 8192/rate (see pipe())
519
+ await asyncio.sleep(-self.tokens / self.rate)
520
+
521
+ @contextlib.asynccontextmanager
522
+ async def slot(peer):
523
+ # Handle --max-conns slots
524
+ # asynccontextmanager makes this usable with 'async with'
525
+ if _SLOTS is None: yield; return
526
+ if _SLOTS.locked(): LOG.debug(f"{peer} waiting for a free slot")
527
+ await asyncio.wait_for(_SLOTS.acquire(), QUEUE_TIMEOUT or None)
528
+ try: yield
529
+ # always release the slot no matter how we exit here
530
+ finally: _SLOTS.release()
531
+
532
+
533
+ # ── Connections ─────────────────────────────────────────────────────────
534
+ # Combine everything above to handle the connections
535
+
536
+ async def approved_targets(peer, host, atyp, port):
537
+ # Ports first: judging one needs no lookup and no connection
538
+ allowed, rule = _RULESET.check_rules("port", port)
539
+ if not allowed: raise Denied(rule or "port not allowed by ruleset")
540
+
541
+ # Clients may put an IP literal in a DOMAIN-type request and looking those
542
+ # up would NXDOMAIN, so treat them as what they are
543
+ if atyp != 3 or is_ip(host):
544
+ allowed, rule = _RULESET.check_rules("ip", ipaddress.ip_address(host))
545
+ if not allowed: raise Denied(rule or "address not allowed by ruleset")
546
+ return [host]
547
+
548
+ # Apply the domain name rules and resolve it to an IP
549
+ allowed, rule = _RULESET.check_rules("name", host.lower())
550
+ if not allowed: raise Denied(rule or "domain name not allowed by ruleset")
551
+ addresses = await asyncio.wait_for(resolve_target(host), CONNECT_TIMEOUT)
552
+ if addresses is None: return [host]
553
+ if not addresses: raise DNSError("no A or AAAA record")
554
+
555
+ # resolving may return several IPs which we all need to check because
556
+ # we don't know which one will be chosen afterwards (in handle())
557
+ kept, dropped = [], []
558
+ for address in addresses:
559
+ allowed, rule = _RULESET.check_rules("ip", ipaddress.ip_address(address))
560
+ if allowed: kept.append(address)
561
+ else: dropped.append(f"{address} ({rule or 'not allowed by ruleset'})")
562
+ if dropped: log_event("dropped", f"{host}: {head_elements(dropped, 3, ', ')}", peer)
563
+ if not kept: raise Denied(f"all {len(addresses)} resolved addresses not allowed by ruleset")
564
+ return kept
565
+
566
+ _ENDED = {ConnectionResetError: "reset", BrokenPipeError: "broken pipe",
567
+ ConnectionAbortedError: "aborted", TimeoutError: "timed out"}
568
+
569
+ def ended_reason_summary(error):
570
+ if error is None: return None
571
+ return _ENDED.get(type(error)) or f"{type(error).__name__}{f': {error}' if str(error) else ''}"
572
+
573
+ def closing_summary(client_to_target, target_to_client):
574
+ (sent, sending_error), (received, receiving_error) = client_to_target, target_to_client
575
+ counts = f"sent {human(sent)}, received {human(received)}"
576
+ sending_reason = ended_reason_summary(sending_error)
577
+ receiving_reason = ended_reason_summary(receiving_error)
578
+
579
+ if sending_reason is None and receiving_reason is None: return counts
580
+ if sending_reason == receiving_reason: return f"{counts} - {sending_reason}"
581
+ if receiving_reason is None: return f"{counts} - {sending_reason} while sending"
582
+ if sending_reason is None: return f"{counts} - {receiving_reason} while receiving"
583
+ return f"{counts} - {sending_reason} while sending; {receiving_reason} while receiving"
584
+
585
+ def host_entry(host):
586
+ # what a host has been up to since it was last summarised
587
+ return _active.setdefault(host, {
588
+ "connections": 0, "ports": {}, "sent": 0, "received": 0, "live": 0, "denied": 0,
589
+ "denied_ports": {}, "reasons": {}, "announced": set(), "last_seen": time.monotonic()})
590
+
591
+ def record_connection(host, port, target):
592
+ entry = host_entry(host)
593
+ if ("connect", port, target) not in entry["announced"]:
594
+ entry["announced"].add(("connect", port, target))
595
+ log_event("connect", f"{host}:{port}" + (f" -> {target}" if target != host else ""))
596
+ entry["connections"] += 1
597
+ entry["ports"][port] = None
598
+ entry["live"] += 1
599
+ entry["last_seen"] = time.monotonic()
600
+ _session["connections"] += 1
601
+
602
+ def record_denial(host, port, reason, peer):
603
+ entry = host_entry(host)
604
+ if ("denied", port, reason) not in entry["announced"]:
605
+ entry["announced"].add(("denied", port, reason))
606
+ log_event("denied", f"{host}:{port} ({reason})", peer)
607
+ entry["denied"] += 1
608
+ entry["denied_ports"][port] = None
609
+ entry["reasons"][reason] = None
610
+ entry["last_seen"] = time.monotonic()
611
+ _session["denied"] += 1
612
+
613
+ def record_close(host, sides):
614
+ (sent, _), (received, _) = sides
615
+ entry = _active[host]
616
+ entry["sent"] += sent
617
+ entry["received"] += received
618
+ entry["live"] -= 1
619
+ entry["last_seen"] = time.monotonic()
620
+ _session["sent"] += sent
621
+ _session["received"] += received
622
+
623
+ def summarise_host(host):
624
+ entry = _active.pop(host)
625
+ made, refused, parts = entry["connections"], entry["denied"], []
626
+ if made:
627
+ ports = head_elements([f":{port}" for port in entry["ports"]], 4, ", ")
628
+ parts.append(f"{made} connection{'' if made == 1 else 's'} on {ports}")
629
+ if refused:
630
+ ports = head_elements([f":{port}" for port in entry["denied_ports"]], 4, ", ")
631
+ parts.append(f"{refused} denied on {ports} ({head_elements(list(entry['reasons']), 2, '; ')})")
632
+ counts = f" - sent {human(entry['sent'])}, received {human(entry['received'])}" if made else ""
633
+ log_event("summary", f"{host}: {', '.join(parts)}{counts}")
634
+
635
+ async def summarise_quiet_hosts():
636
+ while True:
637
+ await asyncio.sleep(1)
638
+ now = time.monotonic()
639
+ for host in [host for host, entry in _active.items()
640
+ if not entry["live"] and now - entry["last_seen"] > HOST_QUIET]:
641
+ summarise_host(host)
642
+
643
+ def summarise_session():
644
+ for host in list(_active): summarise_host(host)
645
+ seconds = int(time.monotonic() - _session["started"])
646
+ spent = f"{seconds // 60}m{seconds % 60:02d}s" if seconds >= 60 else f"{seconds}s"
647
+ LOG.info("=====================")
648
+ made = _session["connections"]
649
+ log_event("session", f"{spent}: {made} connection{'' if made == 1 else 's'},"
650
+ f" {_session['denied']} denied"
651
+ f" - sent {human(_session['sent'])}, received {human(_session['received'])}")
652
+
653
+ async def pipe(reader, writer, buckets):
654
+ # Shovel bytes one way until EOF, then half-close so the peer sees the end
655
+ # Smaller read sizes keep a throttled stream smooth
656
+ # Returns (bytes moved, what ended it)
657
+ size = 8192 if buckets else 65536
658
+ moved, ended = 0, None
659
+ try:
660
+ while chunk := await reader.read(size):
661
+ for bucket in buckets:
662
+ await bucket.take(len(chunk))
663
+ writer.write(chunk)
664
+ await writer.drain()
665
+ moved += len(chunk)
666
+ except Exception as e: ended = e
667
+ finally:
668
+ # the peer may already be gone, in which case there is no one to tell
669
+ try: writer.write_eof()
670
+ except Exception: pass
671
+ return moved, ended
672
+
673
+ async def handle(client_reader, client_writer):
674
+ peer = "%s:%s" % (client_writer.get_extra_info("peername") or ("?", "?"))[:2]
675
+ target_writer = None
676
+ # hold a reference to the task of a connection to ensure our cleanup runs even
677
+ # when both ends of the connection are gone (prevent it from being terminated
678
+ # by the garbage collector because asyncio only holds a weak reference and
679
+ # the stream protocol drops it in connection_lost())
680
+ task = asyncio.current_task()
681
+ _running.add(task)
682
+ try:
683
+ request = await asyncio.wait_for(
684
+ read_handshake(client_reader, client_writer, peer), HANDSHAKE_TIMEOUT)
685
+ if request is None: return
686
+
687
+ # Judge the request; refuse unknown address types (0x08) and any
688
+ # command other than CONNECT (0x07)
689
+ cmd, atyp, host, port = request
690
+ if host is None: return await send_reply(client_writer, BAD_ADDRESS)
691
+ if cmd != 1: return await send_reply(client_writer, BAD_COMMAND)
692
+ if not 0 < port <= 65535: return await send_reply(client_writer, FAILED)
693
+
694
+ # Apply the ruleset and resolve, 0x02 is "not allowed by ruleset" and
695
+ # 0x04 "host unreachable" for a lookup that genuinely failed
696
+ try: targets = await approved_targets(peer, host, atyp, port)
697
+ except Denied as denial:
698
+ record_denial(host, port, str(denial), peer)
699
+ return await send_reply(client_writer, NOT_ALLOWED)
700
+ except Exception as e:
701
+ log_event("resolve", f"{host} failed: {e or type(e).__name__}", peer)
702
+ return await send_reply(client_writer, UNREACHABLE)
703
+
704
+ try:
705
+ async with slot(peer):
706
+ # Try each address in turn and the first to connect wins
707
+ # Only if every one fails do we return a general failure (0x01)
708
+ failures = []
709
+ for target in targets:
710
+ try:
711
+ target_reader, target_writer = await connect_out(target, port)
712
+ break
713
+ except Exception as e:
714
+ failures.append(f"{target} ({e or type(e).__name__})"
715
+ if len(targets) > 1 else f"{e or type(e).__name__}")
716
+ LOG.debug(f"{peer} connect {target}:{port} failed: {e or type(e).__name__}")
717
+ else:
718
+ log_event("failed", f"{host}:{port} - {head_elements(failures, 3, ', ')}", peer)
719
+ return await send_reply(client_writer, FAILED)
720
+ LOG.debug(f"{peer} {host}:{port} -> {target}")
721
+ record_connection(host, port, target)
722
+
723
+ # Both directions share the buckets, so a rate is the total for
724
+ # the connection rather than per direction
725
+ buckets = [b for b in (_RATE, RATE_PER_CONN and TokenBucket(RATE_PER_CONN)) if b]
726
+ await send_reply(client_writer, OK)
727
+ started = time.monotonic()
728
+ to_target = asyncio.create_task(pipe(client_reader, target_writer, buckets))
729
+ to_client = asyncio.create_task(pipe(target_reader, client_writer, buckets))
730
+ # waits for the first EOF, so a connection where neither side ever sends one is
731
+ # never timed out here and keeps its --max-conns slot
732
+ # decided against a timeout and TCP keepalives because the tools/clients should
733
+ # handle those cases themselves
734
+ await asyncio.wait((to_target, to_client), return_when=asyncio.FIRST_COMPLETED)
735
+ # Connections where only one side is closed should get a chance to finish before we time them out
736
+ one_sided = [side for side in (to_target, to_client) if not side.done()]
737
+ if one_sided:
738
+ _, still_running = await asyncio.wait(one_sided, timeout=HALF_OPEN_TIMEOUT)
739
+ if still_running:
740
+ log_event("closed", f"{host}:{port} after the other direction ended {HALF_OPEN_TIMEOUT}s ago", peer)
741
+ # closing instead of cancelling, to keep the byte counts
742
+ target_writer.close()
743
+ client_writer.close()
744
+ sides = await asyncio.gather(to_target, to_client, return_exceptions=True)
745
+ record_close(host, sides)
746
+ LOG.debug(f"{peer} {host}:{port} closed after {time.monotonic() - started:.1f}s, {closing_summary(*sides)}")
747
+ except asyncio.TimeoutError:
748
+ log_event("timeout", f"{host}:{port} gave up waiting for a slot", peer)
749
+ return await send_reply(client_writer, FAILED)
750
+ except (asyncio.IncompleteReadError, ConnectionResetError, BrokenPipeError):
751
+ pass
752
+ except asyncio.TimeoutError:
753
+ # only the handshake should reach here, the slot wait is caught above
754
+ LOG.debug(f"{peer} gave up on the handshake after {HANDSHAKE_TIMEOUT}s")
755
+ except Exception as e:
756
+ # Log if a connection to our listener is closed unexpectedly (e.g. the peer sends garbage)
757
+ LOG.debug(f"{peer} dropped: {e or type(e).__name__}")
758
+ finally:
759
+ if target_writer is not None: target_writer.close()
760
+ client_writer.close()
761
+ _running.discard(task)
762
+
763
+
764
+ # ── Resolved rules ──────────────────────────────────────────────────────
765
+ # Handle --resolve-rules which is ruleset and DNS at once
766
+
767
+ async def resolve_rules(startup=False):
768
+ # resolve the domain names from the arguments to IPs
769
+ unreachable, gone = [], []
770
+ for rule in _RULESET.resolveable_rules():
771
+ name = rule.value[0]
772
+ # resolve without the cache, so TTL_MIN cannot silently cap how fast
773
+ # --resolve-rules-every reacts to a changed answer
774
+ _cache.pop(name, None)
775
+ try:
776
+ addresses = await resolve_target(name) or []
777
+ except Exception as e:
778
+ unreachable.append((name, f"{name} ({e})"))
779
+ continue
780
+ if not addresses:
781
+ # the server did answer but we have no address at the moment
782
+ gone.append(name)
783
+ resolved = []
784
+ for address in addresses:
785
+ resolved += parse_ip_rule(address, rule.verdict, f"{address} (from {name})")
786
+ _RULESET.reindex_resolved_name(name, resolved)
787
+
788
+ if startup:
789
+ # Fail at startup to prevent typos and unintended rules
790
+ if unreachable or gone:
791
+ raise DNSError("could not resolve " + ", ".join(
792
+ [text for _, text in unreachable] + [f"{n} (no address record)" for n in gone]))
793
+ else:
794
+ # Allow a grace period at runtime so a single lost packet or a restarting
795
+ # DNS server does not take the tunnel down
796
+ # We want to fail however if rules can not be verified anymore
797
+ grace = 3 * RESOLVE_RULES_EVERY
798
+ expired = [text for name, text in unreachable
799
+ if _RULESET.unconfirmed_for(name) > grace]
800
+ if expired:
801
+ raise DNSError("could not re-resolve " + ", ".join(expired) + " for over "
802
+ f"{grace}s - their IP rules cannot be verified, so stopping")
803
+ if unreachable:
804
+ LOG.warning(f"could not re-resolve {', '.join(t for _, t in unreachable)}"
805
+ f" - keeping their previous addresses, stopping if they are still "
806
+ f"unconfirmed after {grace}s")
807
+ if gone:
808
+ LOG.info(f"note: {', '.join(gone)} no longer resolve, their address rules are dropped")
809
+
810
+ global _resolved_log
811
+ resolved = [rule for rules in _RULESET.resolved.values() for rule in rules]
812
+ summary = f"{len(_RULESET.resolveable_rules())} domain name rule(s) resolved into {len(resolved)}"
813
+ if resolved: summary += f": {head_elements(resolved, 4, ', ')}"
814
+ changed, _resolved_log = summary != _resolved_log, summary
815
+ LOG.log(logging.INFO if startup or changed else logging.DEBUG, summary)
816
+
817
+ async def refresh_rules():
818
+ # Regularly refresh the resolved rules
819
+ while True:
820
+ await asyncio.sleep(RESOLVE_RULES_EVERY)
821
+ await resolve_rules()
822
+
823
+
824
+ # ── Arguments ───────────────────────────────────────────────────────────
825
+
826
+ def parse_hostport(s, default_port, default_host=None):
827
+ # Parse ipv4 and ipv6 host[:port] combinations
828
+ def parse_port(text):
829
+ if not text.isdigit() or not 0 < int(text) <= 65535:
830
+ raise ValueError(f"'{text}' is not a port from 1 to 65535")
831
+ return int(text)
832
+
833
+ # Handle obvious single IP cases
834
+ if is_ip(s): return (s, default_port)
835
+ if s.startswith("["):
836
+ host, _, rest = s[1:].partition("]")
837
+ return (host, parse_port(rest[1:]) if rest.startswith(":") else default_port)
838
+ host, sep, port = s.rpartition(":")
839
+ if not sep:
840
+ # Single values we treat as a port if we have a default host
841
+ if default_host and s.isdigit():
842
+ return (default_host, parse_port(s))
843
+ return (s, default_port)
844
+ # The easy explicit case (we got ip:port or :port)
845
+ return (host or default_host or "", parse_port(port))
846
+
847
+ _DNS_SIDES = {"u": "upstream", "upstream": "upstream", "l": "local", "local": "local"}
848
+
849
+ def parse_dns(text):
850
+ # Parse our dns grammar [u|upstream|l|local][:SERVER[:PORT][:tcp|:udp]]
851
+ side, _, rest = text.partition(":")
852
+ side = side.lower()
853
+ if side not in _DNS_SIDES:
854
+ raise argparse.ArgumentTypeError(f"'{text}' is not a valid --dns argument - check "
855
+ "out --help on how to specify it!")
856
+ side = _DNS_SIDES[side]
857
+ if not rest: return side, None, None
858
+
859
+ server, _, transport = rest.rpartition(":")
860
+ transport = transport.lower()
861
+ if transport in ("tcp", "udp"): rest = server
862
+ else: transport = "tcp" if side == "upstream" else "udp"
863
+ if not rest:
864
+ raise argparse.ArgumentTypeError(f"'{text}' names no DNS server")
865
+ if side == "upstream" and transport == "udp":
866
+ raise argparse.ArgumentTypeError("upstream with udp DNS does not work - check "
867
+ "out --help and the README on Github for explanations!")
868
+ try: address, port = parse_hostport(rest, 53)
869
+ except ValueError as e: raise argparse.ArgumentTypeError(
870
+ f"'{rest}' is not a SERVER[:PORT]: {e}") from None
871
+ # a domain name here would need a resolver before we have one
872
+ if not is_ip(address):
873
+ raise argparse.ArgumentTypeError(
874
+ f"'{address}' is not an IP address - resolve it yourself first if it is a domain name!")
875
+ return side, (address, port), transport
876
+
877
+ def size(text):
878
+ # Convert human readable sizes to numbers
879
+ factor = {"k": 1024, "m": 1024 ** 2, "g": 1024 ** 3}.get(text[-1:].lower(), 1)
880
+ try: number = float(text.rstrip("kmgKMG"))
881
+ except ValueError: number = -1
882
+ if number < 0:
883
+ raise argparse.ArgumentTypeError(f"'{text}' is not a size like 1M, 512k or 4096")
884
+ if int(number * factor) < 1:
885
+ raise argparse.ArgumentTypeError(f"'{text}' is not a rate anything can flow at")
886
+ return int(number * factor)
887
+
888
+ def human(count):
889
+ # Convert numbers to human readable sizes
890
+ for unit, factor in (("G", 1024 ** 3), ("M", 1024 ** 2), ("k", 1024)):
891
+ if count >= factor: return f"{count / factor:.1f}{unit}"
892
+ return f"{count}B"
893
+
894
+ def head_elements(items, limit, sep=" "):
895
+ items = [str(item) for item in items]
896
+ if len(items) <= limit or LOG.isEnabledFor(logging.DEBUG): return sep.join(items)
897
+ return sep.join(items[:limit]) + f"{sep}+{len(items) - limit} more"
898
+
899
+ _description = """\
900
+ socksscope - a SOCKS5 front-end that lets you manage your traffic and keep it
901
+ inside your engagement scope. Can wrap an existing SOCKS5 port (--upstream)
902
+ or act independently (--local).
903
+
904
+ Every CONNECT is judged against a ruleset of domain names, addresses and ports,
905
+ given as arguments or in files at startup. Additionally you can specify a DNS
906
+ server to use and throttle connection speeds.
907
+
908
+ While it's possible to use socksscope securely, this is a pentesting/redteaming
909
+ tool and NOT a privacy tool. There are a lot of ways to misconfigure socksscope!
910
+ """
911
+
912
+ _description_rules_dns_warn = """\
913
+ Watch out for unexpected rulesets when combining IP address and domain name rules.
914
+ (Check the README on Github for more information!)
915
+ """
916
+
917
+ _epilog_short = """\
918
+ Run '%(prog)s --help' for the full help!
919
+ """
920
+
921
+ _epilog_full = """\
922
+ For more detailed examples, reasonings behind design decisions as well as an in-depth
923
+ explanation of socksscope's DNS resolving (especially when wrapping a SOCKS5 port and
924
+ actively resolving domain name rules using --resolve-rules) check the README on Github.
925
+
926
+ examples:
927
+ %(prog)s -u 1080 --dns u:10.0.0.53 --allow @scope.txt
928
+ %(prog)s -u 1080 --resolve-rules --allow 'intranet.corp.local' --allow :443
929
+ %(prog)s --local --allow 10.0.0.0/8 --rate 1M --max-conns 20
930
+ %(prog)s --allow @scope.txt --test-ruleset admin.corp.local:445
931
+ """
932
+
933
+ def build_parser(full=False):
934
+ # Help got too long so splitting it into '-h' and '--help'
935
+ def help_text(short_help=None, long_help=""):
936
+ if full: return (f"{short_help} " if short_help else "") + long_help
937
+ else: return short_help if short_help else argparse.SUPPRESS
938
+
939
+ p = argparse.ArgumentParser(
940
+ add_help=False,
941
+ description=_description + _description_rules_dns_warn,
942
+ formatter_class=argparse.RawDescriptionHelpFormatter,
943
+ epilog=_epilog_full if full else _epilog_short)
944
+
945
+ p.add_argument("-h", action="help",
946
+ help="show a short help message and exit")
947
+ p.add_argument("--help", action="help",
948
+ help="show the full help and exit")
949
+ p.add_argument("-l", "--listen", metavar="[HOST:]PORT",
950
+ help=help_text(short_help=f"SOCKS5 port %(prog)s opens (default: {LISTEN[0]}:{LISTEN[1]})",
951
+ long_help="- (restricted) SOCKS5 port where the client programs connect to"))
952
+ p.add_argument("--listen-auth", metavar="USER:PASS",
953
+ help=help_text(long_help="optional credentials for the SOCKS5 port socksscope opens"))
954
+ p.add_argument("--local", action="store_true",
955
+ help=help_text(short_help="no SOCKS5 proxy to wrap, run %(prog)s independently",
956
+ long_help="- connect out from this host while enforcing the ruleset"))
957
+ p.add_argument("-u", "--upstream", metavar="[HOST:]PORT",
958
+ help=f"the existing SOCKS5 proxy that will be wrapped (default: {UPSTREAM[0]}:{UPSTREAM[1]})")
959
+ p.add_argument("--upstream-auth", metavar="USER:PASS",
960
+ help=help_text(long_help="optional credentials for the upstream proxy"))
961
+ p.add_argument("-v", "--verbose", action="store_true",
962
+ help=help_text(long_help="log every connection with additional information"))
963
+ p.add_argument("-q", "--quiet", action="store_true",
964
+ help=help_text(long_help="log warnings only"))
965
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}",
966
+ help=help_text(long_help="show the version and exit"))
967
+
968
+ ruleset = p.add_argument_group("ruleset")
969
+ ruleset.add_argument("--allow", action="append", metavar="RULE",
970
+ help=help_text(short_help="domain name, IP address or port rule to allow (repeatable)",
971
+ long_help="- Rules: domain.tld | *.domain.tld | * | IP | IP/NET | "
972
+ "IP-IP | :PORT | :PORT-PORT - @FILE loads a "
973
+ "rules file - ranges include both ends "
974
+ "- '!' inverts a rule"))
975
+ ruleset.add_argument("--block", action="append", metavar="RULE",
976
+ help=help_text(short_help="same as --allow but blocked instead (repeatable)",
977
+ long_help="- syntax exactly like --allow"))
978
+ ruleset.add_argument("--test-ruleset", action="append", metavar="HOST[:PORT]",
979
+ help=help_text(short_help="print how a target would be judged, then exit (repeatable)",
980
+ long_help="- a target without a PORT is judged as :80"))
981
+ ruleset.add_argument("--resolve-rules", action="store_true",
982
+ help="resolve domain name rules and apply them as IP address rules (results in repeating queries)")
983
+ ruleset.add_argument("--yes-resolve-rules", action="store_true",
984
+ help=help_text(long_help="answer the startup --resolve-rules confirmation with yes"))
985
+ ruleset.add_argument("--resolve-rules-every", type=int, metavar="SEC",
986
+ help=help_text(long_help="interval to re-resolve domain name rules (see --resolve-rules) - if a "
987
+ "domain name rule is unconfirmed for three intervals socksscope exits - "
988
+ f"use 0 to resolve only once at startup (default: {RESOLVE_RULES_EVERY})"))
989
+
990
+ dns_group = p.add_argument_group("DNS")
991
+ dns_group.add_argument("--dns", type=parse_dns, metavar="SIDE[:SERVER[:PORT][:tcp|udp]]",
992
+ help=help_text(short_help="specify where DNS queries should be resolved and what protocol to use",
993
+ long_help="- check the README on Github for explanations of all combinations "
994
+ "- SIDE=[u|upstream] to resolve through the wrapped SOCKS5 "
995
+ "- SIDE=[l|local] to resolve via the host socksscope is running on "
996
+ "- [:SERVER[:PORT]] optionally specify a DNS server "
997
+ "- [:tcp|:udp] optionally specify the DNS transport protocol"))
998
+ dns_group.add_argument("--hosts", action="append", metavar="ENTRY",
999
+ help=help_text(short_help="static mapping used before any DNS (like /etc/hosts) (repeatable)",
1000
+ long_help="- 'name=ADDRESS' or 'ADDRESS name' or '@FILE' to load a list"))
1001
+
1002
+ limits = p.add_argument_group("limits")
1003
+ limits.add_argument("--rate", type=size, metavar="SIZE",
1004
+ help=help_text(short_help="total bytes/s over all connections",
1005
+ long_help="(e.g. 1M, 512k)"))
1006
+ limits.add_argument("--rate-per-conn", type=size, default=0, metavar="SIZE",
1007
+ help=help_text(long_help="bytes/s for a single connection"))
1008
+ limits.add_argument("--max-conns", type=int, metavar="N",
1009
+ help=help_text(short_help="connections to run at once",
1010
+ long_help="- the rest queue instead of failing"))
1011
+ limits.add_argument("--queue-timeout", type=int, metavar="SEC",
1012
+ help=help_text(long_help="give up queueing after this long, 0 waits "
1013
+ f"forever (default: {QUEUE_TIMEOUT})"))
1014
+ return p
1015
+
1016
+
1017
+ # ── Startup ─────────────────────────────────────────────────────────────
1018
+
1019
+ def resolver_string():
1020
+ if not DNS_SERVER: return "the system resolver"
1021
+ return (f"{DNS_SERVER[0]}:{DNS_SERVER[1]} over {DNS_TRANSPORT.upper()}"
1022
+ + (" through the upstream" if DNS_SIDE == "upstream" else ""))
1023
+
1024
+ def confirm_rule_resolving(parser, assume_yes, test=False):
1025
+ # prompt the user before sending the first queries when using --resolve-rules
1026
+ names = sorted({rule.value[0] for rule in _RULESET.resolveable_rules()})
1027
+ skipped = _RULESET.wildcard_rules()
1028
+ if skipped:
1029
+ print(f"warning: {len(skipped)} wildcard rule(s) cannot be resolved and stay "
1030
+ f"name-only: {', '.join(str(rule) for rule in skipped)}", file=sys.stderr)
1031
+ if not names:
1032
+ parser.error("--resolve-rules needs at least one domain name rule that is not a wildcard")
1033
+
1034
+ # statically mapped hosts are not answered via network anyway so skip them
1035
+ asked = [name for name in names if name not in HOSTS]
1036
+ if not asked: return True
1037
+
1038
+ if test: when = "This is done once at startup, only to answer --test-ruleset."
1039
+ elif RESOLVE_RULES_EVERY:
1040
+ when = (f"They are re-resolved every {RESOLVE_RULES_EVERY} seconds, and one still "
1041
+ f"unconfirmed after {3 * RESOLVE_RULES_EVERY} seconds stops the tool.")
1042
+ else: when = "They are resolved once, at startup."
1043
+ print(f"\n\n--resolve-rules will actively query {resolver_string()} for {len(asked)} name(s):\n"
1044
+ f" {', '.join(asked)}\n"
1045
+ "Their resolved addresses will be applied as IP rules! " + when, file=sys.stderr)
1046
+
1047
+ if assume_yes: return True
1048
+ if not sys.stdin.isatty():
1049
+ if test: return False
1050
+ parser.error("--resolve-rules wants a confirmation and there is no terminal to ask; "
1051
+ "pass --yes-resolve-rules if you meant it")
1052
+ return input("Send these queries now? [y/N] ").strip().lower() in ("y", "yes")
1053
+
1054
+ def confirm_test_queries_resolving(targets, has_ip_rules):
1055
+ names = sorted({name for name in (parse_hostport(t, None)[0].lower() for t in targets)
1056
+ if not is_ip(name) and name not in HOSTS})
1057
+ if (not names or (DNS_SIDE == "upstream" and DNS_SERVER is None) or not has_ip_rules):
1058
+ return False
1059
+ print(f"\n\n--test-ruleset will actively query {resolver_string()} for {len(names)} name(s):\n"
1060
+ f" {', '.join(names)}\n"
1061
+ "so they can be judged against the IP rules, the way a real run does.", file=sys.stderr)
1062
+ return sys.stdin.isatty() and input("Send these queries now? [y/N] ").strip().lower() in ("y", "yes")
1063
+
1064
+ async def test_ruleset(targets, resolved=False):
1065
+ global DNS_SIDE, DNS_SERVER
1066
+ has_ip_rules = any(rule.type == "ip" for rule in _RULESET.active)
1067
+ why_unresolved = ("the upstream resolves it, so socksscope never sees its address"
1068
+ if DNS_SIDE == "upstream" and DNS_SERVER is None else "you chose not to resolve the name here")
1069
+ if not confirm_test_queries_resolving(targets, has_ip_rules):
1070
+ # misuse "upstream" resolving for this case because it already resolves nothing
1071
+ DNS_SIDE, DNS_SERVER = "upstream", None
1072
+ if RESOLVE_RULES and not resolved:
1073
+ print("\n\nnote: you chose not to resolve the domain name rules, so some address rules "
1074
+ "might be missing from the test ruleset\n", file=sys.stderr)
1075
+ print("=====================\n")
1076
+ denied = 0
1077
+ for target in targets:
1078
+ host, port = parse_hostport(target, None)
1079
+ if port is None:
1080
+ port, target = 80, f"[{host}]:80" if ":" in host else f"{host}:80"
1081
+ try:
1082
+ reached = await approved_targets("--test-ruleset", host, 1 if is_ip(host) else 3, port)
1083
+ print(f"{target:34} => ALLOW -> {head_elements(reached, 4)}")
1084
+ # nothing was substituted for the name, so no address was ever judged
1085
+ if has_ip_rules and not is_ip(host) and reached == [host]:
1086
+ print(f" (not judged against the IP rules: {why_unresolved})")
1087
+ except Denied as denial:
1088
+ print(f"{target:34} => DENY ({denial})")
1089
+ denied += 1
1090
+ except Exception as e:
1091
+ print(f"{target:34} => UNREACHABLE ({e or type(e).__name__})")
1092
+ denied += 1
1093
+ return 1 if denied else 0
1094
+
1095
+ async def serve():
1096
+ if RESOLVE_RULES:
1097
+ # fails if a rule can't be resolved because we don't want silently missing rules
1098
+ await resolve_rules(startup=True)
1099
+
1100
+ server = await asyncio.start_server(handle, *LISTEN)
1101
+ # always name the resolver, it decides how much the IP rules get to see
1102
+ resolver = (f"{DNS_SERVER[0]}:{DNS_SERVER[1]}/{DNS_TRANSPORT} ({DNS_SIDE})" if DNS_SERVER else DNS_SIDE)
1103
+ LOG.info(f"listening on {LISTEN[0]}:{LISTEN[1]} -> "
1104
+ + ("local" if UPSTREAM is None else f"socks {UPSTREAM[0]}:{UPSTREAM[1]}")
1105
+ + f", dns {resolver}{', authenticated' if LISTEN_AUTH[0] is not None else ''}")
1106
+ # Print the complete ruleset (including defaults) at startup
1107
+ for text in _RULESET.default_allows(): LOG.info(f" rule allow {text} (default)")
1108
+ # deduplicated by text: a range is several rules internally but one to read
1109
+ for text in dict.fromkeys(str(rule) for rule in _RULESET.active): LOG.info(f" rule {text}")
1110
+ if not _RULESET.active: LOG.info(" no ruleset defined, everything is allowed")
1111
+ LOG.info("")
1112
+ LOG.info("repeated connections to a host are counted, not logged")
1113
+ LOG.info(f"each host is summarised and reset {HOST_QUIET}s after it goes quiet")
1114
+ LOG.info("=====================\n")
1115
+
1116
+ # Server is running since start_server() so we idle here until cancelled
1117
+ # Not serve_forever() which waits for live connections to close on cancellation
1118
+ until_cancelled = asyncio.Event()
1119
+ _session["started"] = time.monotonic()
1120
+ # gathered so errors in the background tasks are handled here
1121
+ tasks = [until_cancelled.wait(), summarise_quiet_hosts()]
1122
+ if RESOLVE_RULES and RESOLVE_RULES_EVERY: tasks.append(refresh_rules())
1123
+ try: await asyncio.gather(*tasks)
1124
+ finally:
1125
+ # stop accepting; asyncio.run() cancels the handlers still running
1126
+ server.close()
1127
+
1128
+ def main():
1129
+ global LISTEN, LISTEN_AUTH, UPSTREAM, UPSTREAM_AUTH, DNS_SIDE, DNS_SERVER, DNS_TRANSPORT
1130
+ global RESOLVE_RULES, RESOLVE_RULES_EVERY, RATE_PER_CONN, QUEUE_TIMEOUT
1131
+ global _RULESET, _RATE, _SLOTS
1132
+
1133
+ p = build_parser(full="--help" in sys.argv[1:])
1134
+ args = p.parse_args()
1135
+ setup_logging(args.verbose, args.quiet)
1136
+
1137
+ # Handle default values defined at the top of the file
1138
+ try:
1139
+ if args.listen: LISTEN = parse_hostport(args.listen, LISTEN[1], LISTEN[0])
1140
+ if args.upstream: UPSTREAM = parse_hostport(args.upstream, UPSTREAM[1], UPSTREAM[0])
1141
+ if args.local: UPSTREAM = None
1142
+ except ValueError as e:
1143
+ p.error(f"Bad endpoint: {e}")
1144
+ RESOLVE_RULES = args.resolve_rules
1145
+ if args.resolve_rules_every is not None: RESOLVE_RULES_EVERY = args.resolve_rules_every
1146
+ RATE_PER_CONN = args.rate_per_conn
1147
+ if args.queue_timeout is not None: QUEUE_TIMEOUT = args.queue_timeout
1148
+ # by default the side the data goes to resolves the names as well
1149
+ DNS_SIDE = "local" if UPSTREAM is None else "upstream"
1150
+ if args.dns: DNS_SIDE, DNS_SERVER, DNS_TRANSPORT = args.dns
1151
+ if args.upstream_auth:
1152
+ user, _, password = args.upstream_auth.partition(":")
1153
+ UPSTREAM_AUTH = (user, password)
1154
+ if args.listen_auth:
1155
+ user, _, password = args.listen_auth.partition(":")
1156
+ LISTEN_AUTH = (user.encode(), password.encode())
1157
+
1158
+ try: _RULESET = Ruleset(args.allow, args.block)
1159
+ except (OSError, ValueError) as e: p.error(f"Bad rule: {e}")
1160
+
1161
+ try: load_hosts(args.hosts)
1162
+ except (OSError, ValueError) as e: p.error(f"Bad host mapping: {e}")
1163
+
1164
+ # Safeguard impossible/weird argument combinations
1165
+ for name, entries in (("--listen", [args.listen]), ("--upstream", [args.upstream]),
1166
+ ("--upstream-auth", [args.upstream_auth]),
1167
+ ("--listen-auth", [args.listen_auth]), ("--allow", args.allow),
1168
+ ("--block", args.block), ("--hosts", args.hosts),
1169
+ ("--test-ruleset", args.test_ruleset)):
1170
+ for entry in entries or []:
1171
+ if entry is not None and entry.strip() == "":
1172
+ p.error(f"{name} was given an empty value")
1173
+ if args.verbose and args.quiet:
1174
+ p.error("--verbose and --quiet can't be used at the same time")
1175
+ if args.local and args.upstream:
1176
+ p.error("--local and --upstream are opposites: pick one")
1177
+ if UPSTREAM is None and args.upstream_auth:
1178
+ p.error("--upstream-auth makes no sense with --local")
1179
+ if args.upstream_auth and ":" not in args.upstream_auth:
1180
+ p.error("--upstream-auth is USER:PASS - write 'user:' for an empty password")
1181
+ if args.listen_auth and ":" not in args.listen_auth:
1182
+ p.error("--listen-auth is USER:PASS - write 'user:' for an empty password")
1183
+ if args.yes_resolve_rules and not RESOLVE_RULES:
1184
+ p.error("--yes-resolve-rules answers a question only --resolve-rules asks")
1185
+ if args.resolve_rules_every is not None and not RESOLVE_RULES:
1186
+ p.error("--resolve-rules-every paces --resolve-rules, which is not on")
1187
+ if RESOLVE_RULES_EVERY < 0:
1188
+ p.error("--resolve-rules-every cannot be negative, 0 resolves only once")
1189
+ if args.queue_timeout is not None and args.max_conns is None:
1190
+ p.error("--queue-timeout only makes sense with --max-conns specified")
1191
+ if QUEUE_TIMEOUT < 0:
1192
+ p.error("--queue-timeout cannot be negative, 0 waits forever")
1193
+ if args.max_conns is not None and args.max_conns < 1:
1194
+ p.error("--max-conns is how many connections run at once, so at least 1")
1195
+ if DNS_SIDE == "upstream" and UPSTREAM is None:
1196
+ p.error("--dns via upstream requires an upstream - you probably want '--dns local'")
1197
+
1198
+ upstream_resolves = DNS_SIDE == "upstream" and DNS_SERVER is None
1199
+ if RESOLVE_RULES and upstream_resolves and any(
1200
+ rule.value[0] not in HOSTS for rule in _RULESET.resolveable_rules()):
1201
+ p.error("--resolve-rules does not work with basic upstream resolving because "
1202
+ "socksscope never sees the IP addresses - must use '--dns u:IP:tcp' for "
1203
+ "--resolve-rules to work with an upstream SOCKS5 proxy.")
1204
+ if upstream_resolves and any(rule.type == "ip" for rule in _RULESET.active):
1205
+ LOG.warning("probable unexpected ruleset! IP address connections can't be evaluated against "
1206
+ "domain name rules and connections made using a domain name can't be evaluated "
1207
+ "against IP address rules. Includes block and allow rules. This is due to using the "
1208
+ "upstream SOCKS5 server as a resolver. Switch to '--dns u:IP:tcp' to prevent that, or "
1209
+ "refuse domain names outright with '--block *' to only allow connections using IP addresses.")
1210
+
1211
+ # Handle the initial resolving for '--resolve-rules'
1212
+ resolve_at_startup = RESOLVE_RULES and confirm_rule_resolving(p, args.yes_resolve_rules, test=bool(args.test_ruleset))
1213
+ if args.test_ruleset:
1214
+ if resolve_at_startup:
1215
+ try: asyncio.run(resolve_rules(startup=True))
1216
+ except (DNSError, OSError) as e: sys.exit(f"{p.prog}: {e}")
1217
+ try: return asyncio.run(test_ruleset(args.test_ruleset, resolved=resolve_at_startup))
1218
+ except ValueError as e: p.error(f"Bad --test-ruleset target: {e}")
1219
+ if RESOLVE_RULES and not resolve_at_startup:
1220
+ sys.exit("Stopping: the initial DNS queries for --resolve-rules were declined.")
1221
+
1222
+ _RATE = TokenBucket(args.rate) if args.rate else None
1223
+ _SLOTS = asyncio.Semaphore(args.max_conns) if args.max_conns else None
1224
+
1225
+ try: asyncio.run(serve())
1226
+ except KeyboardInterrupt: summarise_session()
1227
+ except (DNSError, OSError) as e: sys.exit(f"{p.prog}: {e}")
1228
+
1229
+ if __name__ == "__main__":
1230
+ sys.exit(main())