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())
|