]>
Commit | Line | Data |
---|---|---|
8b82926a MT |
1 | #!/usr/bin/python3 |
2 | ||
539d08da | 3 | import asyncio |
8b82926a | 4 | import datetime |
539d08da | 5 | import io |
af40bcaa | 6 | import ipaddress |
539d08da | 7 | import logging |
e3bc8f21 | 8 | import magic |
af40bcaa | 9 | import struct |
539d08da MT |
10 | import tornado.iostream |
11 | import tornado.tcpserver | |
66862195 | 12 | |
539d08da | 13 | from . import base |
11347e46 | 14 | from .misc import Object |
672150b2 | 15 | from .decorators import * |
66862195 | 16 | |
539d08da MT |
17 | # Setup logging |
18 | log = logging.getLogger(__name__) | |
19 | ||
20 | CHUNK_SIZE = 1024 ** 2 | |
21 | ||
66862195 | 22 | class 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 | ||
162 | class 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 |
259 | PROXY_CMD_LOCAL = 0 | |
260 | PROXY_CMD_PROXY = 1 | |
261 | ||
262 | PROXY_FAMILY_UNSPEC = 0 | |
263 | PROXY_FAMILY_INET = 1 | |
264 | PROXY_FAMILY_INET6 = 2 | |
265 | ||
266 | PROXY_PROTO_UNSPEC = 0 | |
267 | PROXY_PROTO_STREAM = 1 | |
268 | PROXY_PROTO_DGRAM = 2 | |
269 | ||
270 | class ProxyError(Exception): | |
271 | pass | |
272 | ||
273 | ||
274 | class ProxyUnsupportedError(ProxyError): | |
275 | pass | |
276 | ||
277 | ||
539d08da | 278 | class 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 |