]> git.ipfire.org Git - ipfire.org.git/blame - src/backend/nopaste.py
nopaste: Implement PROXY protocol on the TCP service
[ipfire.org.git] / src / backend / nopaste.py
CommitLineData
8b82926a
MT
1#!/usr/bin/python3
2
539d08da 3import asyncio
8b82926a 4import datetime
539d08da 5import io
af40bcaa 6import ipaddress
539d08da 7import logging
e3bc8f21 8import magic
af40bcaa 9import struct
539d08da
MT
10import tornado.iostream
11import tornado.tcpserver
66862195 12
539d08da 13from . import base
11347e46 14from .misc import Object
672150b2 15from .decorators import *
66862195 16
539d08da
MT
17# Setup logging
18log = logging.getLogger(__name__)
19
20CHUNK_SIZE = 1024 ** 2
21
66862195 22class Nopaste(Object):
672150b2
MT
23 def _get_paste(self, query, *args, **kwargs):
24 return self.db.fetch_one(Paste, query, *args, **kwargs)
25
e3bc8f21 26 def create(self, content, subject=None, mimetype=None, expires=None, account=None, address=None):
2268f20b
MT
27 # Store the blob
28 blob_id = self._store_blob(content)
29
e3bc8f21
MT
30 # Guess the mimetype if none set
31 if not mimetype:
32 mimetype = magic.from_buffer(content, mime=True)
33
8b82926a
MT
34 if expires:
35 expires = datetime.datetime.utcnow() + datetime.timedelta(seconds=expires)
36
66862195 37 # http://blog.00null.net/easily-generating-random-strings-in-postgresql/
672150b2 38 paste = self._get_paste("""
2268f20b
MT
39 INSERT INTO
40 nopaste
41 (
42 uuid,
43 subject,
44 content,
45 time_expires,
46 address,
2268f20b
MT
47 mimetype,
48 size,
49 blob_id
50 )
51 VALUES
52 (
4ee29b31 53 random_slug(), %s, %s, %s, %s, %s, %s, %s
2268f20b
MT
54 )
55 RETURNING
672150b2 56 *
4ee29b31 57 """, subject, content, expires or None, address, mimetype, len(content), blob_id,
2268f20b 58 )
66862195 59
672150b2
MT
60 # Log result
61 log.info("Created a new paste (%s) of %s byte(s) from %s (%s - %s)" % (
62 paste.uuid, paste.size, paste.address, paste.asn or "N/A", paste.country or "N/A",
63 ))
64
65 return paste
66
67 def _fetch_blob(self, id):
68 blob = self.db.get("""
69 SELECT
70 data
71 FROM
72 nopaste_blobs
73 WHERE
74 id = %s
75 """, id,
76 )
77
78 if blob:
79 return blob.data
66862195 80
2268f20b
MT
81 def _store_blob(self, data):
82 """
83 Stores the blob by sending it to the database and returning its ID
84 """
85 blob = self.db.get("""
86 INSERT INTO
87 nopaste_blobs
88 (
89 data
90 )
91 VALUES
92 (
93 %s
94 )
95 ON CONFLICT
96 (
0b6fe55a 97 digest(data, 'sha256')
2268f20b
MT
98 )
99 DO UPDATE SET
100 last_uploaded_at = CURRENT_TIMESTAMP
101 RETURNING
102 id
0b6fe55a 103 """, data,
2268f20b
MT
104 )
105
106 # Return the ID
107 return blob.id
108
66862195 109 def get(self, uuid):
672150b2
MT
110 paste = self._get_paste("""
111 SELECT
112 *
113 FROM
114 nopaste
115 WHERE
116 uuid = %s
117 AND (
118 expires_at >= CURRENT_TIMESTAMP
119 OR
120 expires_at IS NULL
121 )
122 """, uuid,
123 )
66862195 124
672150b2 125 return paste
66862195 126
0de07bc3 127 def get_content(self, uuid):
09fa25a8
MT
128 res = self.db.get("SELECT content FROM nopaste \
129 WHERE uuid = %s", uuid)
0de07bc3 130
09fa25a8 131 if res:
a41085fb 132 return bytes(res.content)
0de07bc3 133
063fd092
MT
134 def cleanup(self):
135 """
136 Removes all expired pastes and removes any unneeded blobs
137 """
138 # Remove all expired pastes
139 self.db.execute("""
140 DELETE FROM
141 nopaste
142 WHERE
143 expires_at < CURRENT_TIMESTAMP
144 """)
145
146 # Remove unneeded blobs
147 self.db.execute("""
148 DELETE FROM
149 nopaste_blobs
150 WHERE NOT EXISTS
151 (
152 SELECT
153 1
154 FROM
155 nopaste
156 WHERE
157 nopaste.blob_id = nopaste_blobs.id
158 )
159 """)
539d08da
MT
160
161
162class Paste(Object):
163 def init(self, id, data):
164 self.id, self.data = id, data
165
166 # UUID
167
168 @property
169 def uuid(self):
170 return self.data.uuid
171
172 # Subject
173
174 @property
175 def subject(self):
176 return self.data.subject
177
178 # Created At
179
180 @property
181 def created_at(self):
182 return self.data.created_at
183
184 time_created = created_at
185
186 # Expires At
187
188 @property
189 def expires_at(self):
190 return self.data.expires_at
191
192 time_expires = expires_at
193
194 # Blob
195
196 @lazy_property
197 def blob(self):
198 return self.backend.nopaste._fetch_blob(self.data.blob_id)
199
200 content = blob
201
202 # Size
203
204 @property
205 def size(self):
206 return self.data.size
207
672150b2
MT
208 # MIME Type
209
210 @property
211 def mimetype(self):
212 return self.data.mimetype or "application/octet-stream"
213
539d08da
MT
214 # Address
215
216 @property
217 def address(self):
218 return self.data.address
219
220 # Location
221
222 @lazy_property
223 def location(self):
224 return self.backend.location.lookup("%s" % self.address)
225
226 # ASN
227
228 @lazy_property
229 def asn(self):
230 if self.location and self.location.asn:
231 return self.backend.location.get_asn(self.location.asn)
232
233 # Country
234
235 @lazy_property
236 def country(self):
237 if self.location and self.location.country_code:
238 return self.backend.location.get_country(self.location.country_code)
239
5482e5f2
MT
240 # Viewed?
241
242 def viewed(self):
243 """
244 Call this when this paste has been viewed/downloaded/etc.
245 """
246 self.db.execute("""
247 UPDATE
248 nopaste
249 SET
250 last_accessed_at = CURRENT_TIMESTAMP,
251 views = views + 1
252 WHERE
253 id = %s
254 """, self.id,
255 )
256
539d08da 257
af40bcaa
MT
258# PROXY Protocol Implementation
259PROXY_CMD_LOCAL = 0
260PROXY_CMD_PROXY = 1
261
262PROXY_FAMILY_UNSPEC = 0
263PROXY_FAMILY_INET = 1
264PROXY_FAMILY_INET6 = 2
265
266PROXY_PROTO_UNSPEC = 0
267PROXY_PROTO_STREAM = 1
268PROXY_PROTO_DGRAM = 2
269
270class ProxyError(Exception):
271 pass
272
273
274class ProxyUnsupportedError(ProxyError):
275 pass
276
277
539d08da 278class Service(tornado.tcpserver.TCPServer):
af40bcaa 279 def __init__(self, config, use_proxy=True, **kwargs):
539d08da
MT
280 # Initialize backend
281 self.backend = base.Backend(config)
282
af40bcaa
MT
283 # Expect PROXY headers?
284 self.use_proxy = use_proxy
285
539d08da
MT
286 super().__init__(**kwargs)
287
288 async def handle_stream(self, stream, address):
289 buffer = io.BytesIO()
290
af40bcaa
MT
291 # Parse the PROXY header
292 if self.use_proxy:
293 try:
294 address = await self._parse_proxyv2_header(stream, address)
295
296 # Close the stream on any proxy errors
297 except ProxyError as e:
298 log.error("Proxy Error: %s" % e)
299
300 return stream.close()
301
302 # If we received no result, this connection is not supposed to be relayed
303 if not address:
304 return
305
539d08da
MT
306 # Read the entire stream
307 try:
308 while True:
309 chunk = await stream.read_bytes(CHUNK_SIZE, partial=True)
310
311 log.debug("Read a chunk of %s byte(s)" % len(chunk))
312
313 # Write the chunk into the buffer
314 buffer.write(chunk)
315
316 # If we have read less then we have reached the end
317 if len(chunk) < CHUNK_SIZE:
318 break
319
320 # End if the stream closed unexpectedly
321 except tornado.iostream.StreamClosedError as e:
322 return
323
324 log.debug("Finished reading payload")
325
326 # Process address
327 address, port = address
328
329 # Store this into the database
330 with self.backend.db.transaction():
af40bcaa 331 paste = self.backend.nopaste.create(
539d08da
MT
332 buffer.getvalue(),
333 subject="Streamed Upload",
334 address=address,
335 )
336
337 # Format a response message
af40bcaa 338 message = "https://nopaste.ipfire.org/view/%s\n" % paste.uuid
539d08da
MT
339
340 # Send the message
341 await stream.write(message.encode("utf-8"))
342
343 # We are done, close the stream
344 stream.close()
af40bcaa
MT
345
346 async def _parse_proxyv2_header(self, stream, address):
347 """
348 Parses the PROXYv2 header and returns the real client's IP address
349 """
350 src_address, src_port, dst_address, dst_port = None, None, None, None
351
352 log.debug("Parsing PROXY connection from %s:%s" % address)
353
354 # Header
355 proxy_hdr_v2 = struct.Struct("!12BBBH")
356 proxy_addr_v6 = struct.Struct("!16B16BHH")
357 proxy_addr_v4 = struct.Struct("!IIHH")
358
359 # Try to read the header into a buffer
360 buffer = await stream.read_bytes(proxy_hdr_v2.size)
361
362 if len(buffer) < proxy_hdr_v2.size:
363 raise ProxyError("Header too short")
364
365 # Parse the header
366 *signature, ver_cmd, fam_prot, length = proxy_hdr_v2.unpack(buffer)
367
368 # Check signature
369 if not signature == [13, 10, 13, 10, 0, 13, 10, 81, 85, 73, 84, 10]:
370 raise ProxyError("Incorrect signature")
371
372 # Extract version and command
373 version = (ver_cmd >> 4)
374 command = (ver_cmd & 0x0f)
375
376 # Check protocol version
377 if not version == 2:
378 raise ProxyError("Incorrect protocol version")
379
380 # Handle LOCAL commands
381 if command == PROXY_CMD_LOCAL:
382 pass
383
384 # Handle PROXY commands
385 elif command == PROXY_CMD_PROXY:
386 pass # Fallthrough
387
388 # We don't know any other commands
389 else:
390 log.debug("Unknown PROXY command %02x" % command)
391 return
392
393 # Extract protocol
394 family = (fam_prot >> 4)
395 protocol = (fam_prot & 0x0f)
396
397 # LOCAL command use family == AF_UNSPEC
398 if command == PROXY_CMD_LOCAL and family == PROXY_FAMILY_UNSPEC:
399 pass
400
401 # We accept IPv6 and IPv4
402 elif family in (PROXY_FAMILY_INET6, PROXY_FAMILY_INET):
403 pass
404
405 # Everything else we don't know how to handle here
406 else:
407 raise ProxyError("Unknown family %s" % family)
408
409 # LOCAL commands use protocol == UNSPEC
410 if command == PROXY_CMD_LOCAL and protocol == PROXY_PROTO_UNSPEC:
411 pass
412
413 # Otherwise we only support TCP
414 elif protocol == PROXY_PROTO_STREAM:
415 pass
416
417 # We don't know how to handle anything else here
418 else:
419 raise ProxyUnsupportedError("Unknown or unsupported protocol %s" % protocol)
420
421 # Read the next part of the header into the buffer
422 buffer = await stream.read_bytes(length)
423
424 # Check if we read enough data
425 if len(buffer) < length:
426 raise ProxyError("Header too short")
427
428 # Unpack IPv6 addresses
429 if family == PROXY_FAMILY_INET6:
430 addresses = proxy_addr_v6.unpack(buffer[:proxy_addr_v6.size])
431
432 src_address = ipaddress.IPv6Address(bytes(addresses[:16]))
433 src_port = addresses[-2]
434 dst_address = ipaddress.IPv6Address(bytes(addresses[16:32]))
435 dst_port = addresses[-1]
436
437 # Truncate buffer
438 buffer = buffer[proxy_addr_v6.size:]
439
440 # Unpack IPv4 addresses
441 elif family == PROXY_FAMILY_INET:
442 src_address, dst_address, src_port, dst_port = proxy_addr_v4.unpack(
443 buffer[:proxy_addr_v4.size],
444 )
445
446 # Convert IP addresses
447 src_address = ipaddress.IPv4Address(src_address)
448 dst_address = ipaddress.IPv4Address(dst_address)
449
450 # Truncate buffer
451 buffer = buffer[proxy_addr_v4.size:]
452
453 # Handle UNSPEC
454 elif family == PROXY_FAMILY_UNSPEC:
455 pass
456
457 # Log the result
458 if src_address and dst_address:
459 log.debug("Accepted new connection from %s:%s to %s:%s" \
460 % (src_address, src_port, dst_address, dst_port))
461
462 # Return the source address
463 return "%s" % src_address, src_port