This repository was archived by the owner on Jun 19, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathslimHTTP.py
More file actions
1805 lines (1490 loc) · 61.8 KB
/
Copy pathslimHTTP.py
File metadata and controls
1805 lines (1490 loc) · 61.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import ssl, os, sys, random, json, glob
import ipaddress
import importlib.util, traceback
import logging
from os.path import isfile, abspath
from json import dumps
import time
from mimetypes import guess_type # TODO: issue consern, doesn't handle bytes,
# requires us to decode the string before guessing type.
try:
from OpenSSL.crypto import load_certificate, SSL, crypto, load_privatekey, PKey, FILETYPE_PEM, TYPE_RSA, X509, X509Req, dump_certificate, dump_privatekey
from OpenSSL._util import ffi as _ffi, lib as _lib
except:
class MOCK_CERT_STORE():
def __init__(self):
pass
def add_cert(self, *args, **kwargs):
pass
class SSL():
"""
This is *not* a crypto implementation!
This is a mock class to get the native lib `ssl` to behave like `PyOpenSSL.SSL`.
The net result should be a transparent experience for programmers by default opting out of `PyOpenSSL`.
.. warning::
PyOpenSSL is optional, but certain expectations of behavior might be scewed if you don't have it.
Most importantly, some flags will have no affect unless the optional dependency is met - but the behavior
of the function-call should remain largely the same.
"""
TLSv1_2_METHOD = 0b110
VERIFY_PEER = 0b1
VERIFY_FAIL_IF_NO_PEER_CERT = 0b10
MODE_RELEASE_BUFFERS = 0b10000
def __init__(self):
self.key = None
self.cert = None
def Context(*args, **kwargs):
return SSL()
def set_verify(self, *args, **kwargs):
pass
def set_verify_depth(self, *args, **kwargs):
pass
def use_privatekey_file(self, path, *args, **kwargs):
self.key = path
def use_certificate_file(self, path, *args, **kwargs):
self.cert = path
def set_default_verify_paths(self, *args, **kwargs):
pass
def set_mode(self, *args, **kwargs):
pass
def load_verify_locations(self, *args, **kwargs):
pass
def get_cert_store(self, *args, **kwargs):
return MOCK_CERT_STORE()
def Connection(context, socket):
if type(context) == SSL:
new_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
new_context.load_cert_chain(context.cert, context.key)
context = new_context
socket.settimeout(5)
new_ssl_socket = context.wrap_socket(socket, server_side=True)
new_ssl_socket.settimeout(None)
return new_ssl_socket
from socket import *
try:
from select import epoll, EPOLLIN
except:
import select
EPOLLIN = None
class epoll():
""" #!if windows
Create a epoll() implementation that simulates the epoll() behavior.
This so that the rest of the code doesn't need to worry weither we're using select() or epoll().
"""
def __init__(self):
self.sockets = {}
self.monitoring = {}
def unregister(self, fileno, *args, **kwargs):
try:
del(self.monitoring[fileno])
except:
pass
def register(self, fileno, *args, **kwargs):
self.monitoring[fileno] = True
def poll(self, timeout=0.05, *args, **kwargs):
try:
return [[fileno, 1] for fileno in select.select(list(self.monitoring.keys()), [], [], timeout)[0]]
except OSError:
return []
def splitall(path):
"""
`os.path.split` but splits the entire path
into a list of individual pieces.
Essentially a `str.split('/')` but OS independent.
More or less a solution for of https://stackoverflow.com/questions/3167154/how-to-split-a-dos-path-into-its-components-in-python
based on the answer here: https://stackoverflow.com/a/22444703/929999
Another proposed solution would be (https://stackoverflow.com/a/16595356/929999):
path = os.path.normpath(path)
path.split(os.sep)
Down-side is the empty entry for /root/test.txt but not for C:\root\test.txt
(inconsistency)
:param path: A *Nix or Windows path
:type path: str
:return: A list of the paths element, where the root will be '/' or 'C:\\' depending on platform.
:rtype: str
"""
allparts = []
while 1:
parts = os.path.split(path)
if parts[0] == path: # sentinel for absolute paths
allparts.insert(0, parts[0])
break
elif parts[1] == path: # sentinel for relative paths
allparts.insert(0, parts[1])
break
else:
path = parts[0]
allparts.insert(0, parts[1])
return allparts
os.path.splitall = splitall
def safepath(root, path):
"""
Attempts to make a path safe, as well as remove any traces of
the current working directory after resolve to keep relative paths intact.
:param root: The "jail" in which to constrain the path too
:type root: str
:param path: The relative or absolute path from root
:type path: str
:return: A safe(root+clean(path)) joined string representation of path
:rtype: str
"""
root_abs = os.path.abspath(root)
path_abs = os.path.abspath(os.path.join(*os.path.splitall(path)[1:]))
cwd = os.getcwd()
# Check if the resolved requested path shares a common pathway with the current working directory.
# And if so, strip it away because the next os.path.join() will mess things up otherwise.
common_pathway = os.path.commonprefix([cwd, path_abs])
if common_pathway:
path_abs = path_abs[len(common_pathway):]
# If the new path doesn't start with / we'll have to add it
# otherwise the next splitall() will assume the wrong thing.
# And that assumption is important to not mess up other normal paths that will start with /
start_of_abs_path = os.path.splitall(path_abs)[0]
if start_of_abs_path[0] not in ('\\', '.', '/') and ':\\' not in start_of_abs_path:
path_abs = os.path.join('/', start_of_abs_path)
# Safely join the root and path (minus the leading / of path)
return os.path.join(root_abs, *os.path.splitall(path_abs)[1:])
os.path.safepath = safepath
GLOBAL_POLL_TIMEOUT = 0.001
MAX_MEM_ALLOC = 1024*50
HTTP = 0b0001
HTTPS = 0b0010
instances = {}
def server(mode=HTTPS, *args, **kwargs):
"""
server() is essentially just a router to the appropriate class for the mode selected.
It will create a instance of :ref:`~slimHTTP.HTTP_SERVER` or :ref:`~slimHTTP.HTTPS_SERVER` based on `mode`.
:param mode: Which mode to instanciate (`1` == HTTP, `2` == HTTPS)
:type mode: int
:return: A instance corresponding to the `mode` selected
:rtype: :ref:`~slimHTTP.HTTP_SERVER` or :ref:`~slimHTTP.HTTPS_SERVER`
"""
if mode == HTTPS:
instance = HTTPS_SERVER(*args, **kwargs)
elif mode == HTTP:
instance = HTTP_SERVER(*args, **kwargs)
instances[f'{instance.config["addr"]}:{instance.config["port"]}'] = instance
return instance
def host(*args, **kwargs):
"""
Legacy function, re-routes to server()
"""
print('[Warn] Deprecated function host() called, use server(mode=<mode>) instead.')
return server(*args, **kwargs)
def drop_privileges():
"""
#TODO: implement
Drops the startup privileges to a more suitable production privilege.
:return: The result of the priv-drop
:rtype: bool
"""
return True
def UTF8_DICT(d):
result = {}
for key, val in d.items():
if type(key) == bytes: key = key.decode('UTF-8')
if type(val) == bytes: val = val.decode('UTF-8')
result[key] = val
return result
class FILE():
"""
Whenever a file is to be delivered back, this helper class
can make some return codes and headers easier to use.
Simply put, a class-instance of this class will be yielded
up the event chain, picked up and `.data` or `.chunk` will be
returned to the client/user.
:param request: The request object for the current triggering request
:type request: :ref:`~slimHTTP.HTTP_REQUEST`
:param path: The path to the file requested
:type path: str
"""
def __init__(self, request, path):
# TODO: Grab the path from the request object?
self.request = request
self._path = path
self.fh = None
self.headers_sent = False
if os.path.isfile(self.path):
self.request.ret_code = 200
self.size = os.stat(self.path).st_size
else:
self.request.ret_code = 404
self.size = -1
def __repr__(self):
return f'<slimHTTP.FILE object "{self.path}" at {id(self)}>'
def __enter__(self, *args, **kwargs):
"""
Opens the requested file in a context mode if not already opened.
Will automatically be closed by exiting the context *(__exit__)*.
:return: The `FILE()` instance itself
:rtype: :ref:`~slimHTTP.FILE`
"""
if not os.path.isfile(self.path): return self
if not self.fh: self.fh = open(self.path, 'rb')
return self
def __exit__(self, *args, **kwargs):
if self.fh:
self.fh.close()
@property
def mime(self):
"""
Returns the guessed mime-type of the requested file.
Certain file-ending specifics are also implemented due to the lack
of support from the builting `mimetype.guess_type` library.
:return: The mime-type of the file-ending of the requested file
:rtype: str
"""
mime = guess_type(self.path)[0] #TODO: Deviates from bytes pattern. Replace guess_type()
if not mime and self.path[-4:] == '.iso': mime = 'application/octet-stream'
return mime
@property
def headers(self):
"""
The headers needed for the corresponding requested file.
:return: A dictionary of headers needed to deliver the file safely.
:rtype: dict
"""
return {
b'Content-Type' : bytes(self.mime, 'UTF-8') if self.mime else b'plain/text',
b'Content-Length' : str(self.size)
}
@property
def path(self):
"""
An absolute path of the requested path.
:return: A `os.path.abspath` rendering.
:rtype: str
"""
return os.path.abspath(self._path)
@property
def data(self, size=-1):
"""
Returns the entierty of the requested file.
Does take an optional parameter of `size` to limit the ammount of data returned.
:param size: Limits the ammount of data returned
:type size: int
:return: The contents of the file in byte-representation
:rtype: bytes
"""
if not self.fh: return None
yield self.fh.read(size)
@property
def chunk(self, size=-1):
"""
Returns the entierty of the requested file.
Does take an optional parameter of `size` to limit the ammount of data returned.
:param size: Limits the ammount of data returned
:type size: int
:return: The contents of the file in byte-representation
:rtype: bytes
"""
if not self.fh: return None
return self.fh.read(size)
class STREAM_CHUNKED(FILE):
"""
Behaves in similar fasion to :ref:`~slimHTTP.FILE`, but also supports
streaming content. It keeps track of the current position of the file
as well as implements `.data` and `.chunk` into two different methods.
.. note::
This class has not been tested with streaming content. Only large files.
:param request: The current :ref:`~slimHTTP.HTTP_REQUEST` object from the client
:type request: :ref:`~slimHTTP.HTTP_REQUEST`
:param path: The path to the file requested
:type path: str
"""
def __init__(self, request, path, start=0, chunksize=MAX_MEM_ALLOC):
super(STREAM_CHUNKED, self).__init__(request, path)
if not 'STREAM_CHUNKED' in request.session_storage:
request.session_storage['STREAM_CHUNKED'] = self
self.headers_sent = False
self.chunksize = chunksize
else:
if not start: start = request.session_storage['STREAM_CHUNKED'].pos
self.headers_sent = request.session_storage['STREAM_CHUNKED'].headers_sent
self.chunksize = request.session_storage['STREAM_CHUNKED'].chunksize
self.pos = start
self.EOF = False
# self.ret_code = 206
def __repr__(self):
return f'<slimHTTP.STREAM_CHUNKED object "{self.path}" at {id(self)}>'
def __enter__(self, *args, **kwargs):
if not self.fh: self.fh = open(self.path, 'rb')
self.fh.seek(self.pos)
return self
def __exit__(self, *args, **kwargs):
if self.pos >= self.size:
self.fh.close()
self.fh = None
@property
def headers(self):
return {
b'Content-Type' : bytes(self.mime, 'UTF-8') if self.mime else b'plain/text',
#b'Content-Length' : str(self.size),
b'Transfer-Encoding' : b'chunked'
}
@property
def data(self):
while self.fh.tell() < self.size:
self.pos += self.chunksize # Safe to move forward before yielding due to __enter__
yield self.fh.read(self.chunksize)
@property
def chunk(self):
self.pos += self.chunksize
chunk = self.fh.read(self.chunksize)
if len(chunk) <= 0:
self.EOF = True
return chunk
class CertManager():
"""
CertManager() is a class to handle creation of certificates.
It attempts to use the *optional* PyOpenSSL library, if that fails,
the backup option is to attempt a subprocess.Popen() call to openssl.
.. warning::
Work in progress, most certanly contains errors and issues if *(optionally)* PyOpenSSL isn't present.
"""
def generate_key_and_cert(key_file, **kwargs):
# TODO: Fallback is to use subprocess.Popen('openssl ....')
# since installing additional libraries isn't always possible.
# But a return of None is fine for now.
try:
from OpenSSL.crypto import load_certificate, SSL, crypto, load_privatekey, PKey, FILETYPE_PEM, TYPE_RSA, X509, X509Req, dump_certificate, dump_privatekey
from OpenSSL._util import ffi as _ffi, lib as _lib
except:
return None
# https://gist.github.com/kyledrake/d7457a46a03d7408da31
# https://github.com/cea-hpc/pcocc/blob/master/lib/pcocc/Tbon.py
# https://www.pyopenssl.org/en/stable/api/crypto.html
a_day = 60*60*24
if not 'cert_file' in kwargs: kwargs['cert_file'] = None
if not 'country' in kwargs: kwargs['country'] = 'SE'
if not 'sate' in kwargs: kwargs['state'] = 'Stockholm'
if not 'city' in kwargs: kwargs['city'] = 'Stockholm'
if not 'organization' in kwargs: kwargs['organization'] = 'Evil Scientist'
if not 'unit' in kwargs: kwargs['unit'] = 'Security'
if not 'cn' in kwargs: kwargs['cn'] = 'server'
if not 'email' in kwargs: kwargs['email'] = 'evil@scientist.cloud'
if not 'expires' in kwargs: kwargs['expires'] = a_day*365
if not 'key_size' in kwargs: kwargs['key_size'] = 4096
if not 'ca' in kwargs: kwargs['ca'] = None
priv_key = PKey()
priv_key.generate_key(TYPE_RSA, kwargs['key_size'])
serialnumber=random.getrandbits(64)
if not kwargs['ca']:
# If no ca cert/key was given, assume that we're trying
# to set up a CA cert and key pair.
certificate = X509()
certificate.get_subject().C = kwargs['country']
certificate.get_subject().ST = kwargs['state']
certificate.get_subject().L = kwargs['city']
certificate.get_subject().O = kwargs['organization']
certificate.get_subject().OU = kwargs['unit']
certificate.get_subject().CN = kwargs['cn']
certificate.set_serial_number(serialnumber)
certificate.gmtime_adj_notBefore(0)
certificate.gmtime_adj_notAfter(kwargs['expires'])
certificate.set_issuer(certificate.get_subject())
certificate.set_pubkey(priv_key)
certificate.sign(priv_key, 'sha512')
else:
# If a CA cert and key was given, assume we're creating a client
# certificate that will be signed by the CA.
req = X509Req()
req.get_subject().C = kwargs['country']
req.get_subject().ST = kwargs['state']
req.get_subject().L = kwargs['city']
req.get_subject().O = kwargs['organization']
req.get_subject().OU = kwargs['unit']
req.get_subject().CN = kwargs['cn']
req.get_subject().emailAddress = kwargs['email']
req.set_pubkey(priv_key)
req.sign(priv_key, 'sha512')
certificate = X509()
certificate.set_serial_number(serialnumber)
certificate.gmtime_adj_notBefore(0)
certificate.gmtime_adj_notAfter(kwargs['expires'])
certificate.set_issuer(kwargs['ca'].cert.get_subject())
certificate.set_subject(req.get_subject())
certificate.set_pubkey(req.get_pubkey())
certificate.sign(kwargs['ca'].key, 'sha512')
cert_dump = dump_certificate(FILETYPE_PEM, certificate)
key_dump = dump_privatekey(FILETYPE_PEM, priv_key)
if not os.path.isdir(os.path.abspath(os.path.dirname(key_file))):
os.makedirs(os.path.abspath(os.path.dirname(key_file)))
if not kwargs['cert_file']:
with open(key_file, 'wb') as fh:
fh.write(cert_dump)
fh.write(key_dump)
else:
with open(key_file, 'wb') as fh:
fh.write(key_dump)
with open(kwargs['cert_file'], 'wb') as fh:
fh.write(cert_dump)
return priv_key, certificate
class LoggerError(BaseException):
pass
class InvalidFrame(BaseException):
pass
class slimHTTP_Error(BaseException):
pass
class ModuleError(BaseException):
def __init__(self, message, path):
self.message = message
self.path = path
class ConfError(BaseException):
def __init__(self, message):
self.message = message
pass
class NotYetImplemented(BaseException):
def __init__(self, message):
self.message = message
pass
class UpgradeIssue(BaseException):
def __init__(self, message):
self.message = message
pass
class Events():
"""
Events.<CONST> is a helper class to indicate which event is triggered.
Events are passed up through the event chain deep from within slimHTTP.
These events can be caught in your main `.poll()` loop, and react to different events.
"""
SERVER_ACCEPT = 0b10000000
SERVER_CLOSE = 0b10000001
SERVER_RESTART = 0b00000010
CLIENT_DATA = 0b01000000
CLIENT_REQUEST = 0b01000001
CLIENT_RESPONSE_DATA = 0b01000010
CLIENT_UPGRADED = 0b01000011
CLIENT_UPGRADE_ISSUE = 0b01000100
CLIENT_URL_ROUTED = 0b01000101
CLIENT_DATA_FRAGMENTED = 0b01000110
CLIENT_RESPONSE_PROXY_DATA = 0b01000111
WS_CLIENT_DATA = 0b11000000
WS_CLIENT_REQUEST = 0b11000001
WS_CLIENT_COMPLETE_FRAME = 0b11000010
WS_CLIENT_INCOMPLETE_FRAME = 0b11000011
WS_CLIENT_ROUTED = 0b11000100
WS_CLIENT_RESPONSE = 0b11000110
PROXY_EMPTY_RESPONSE = 0b11100000
NOT_YET_IMPLEMENTED = 0b00000000
INVALID_DATA = 0b00000001
DATA_EVENTS = (CLIENT_RESPONSE_DATA, CLIENT_URL_ROUTED, CLIENT_RESPONSE_PROXY_DATA, WS_CLIENT_RESPONSE)
def convert(_int):
def_map = {v: k for k, v in Events.__dict__.items() if not k.startswith('__') and k != 'convert'}
return def_map[_int] if _int in def_map else None
class _Sys():
modules = {}
specs = {}
class VirtualStorage():
"""
A virtual storage to simulate `sys.modules` but instead be accessed with
`internal.sys.storage` for a internal *"sys"* reference bound to the slimHTTP session.
"""
def __init__(self):
self.sys = _Sys()
self.storage = {}
internal = VirtualStorage()
class Imported():
"""
A wrapper for `import <module>` that instead works like `Imported(<module>)`.
It also supports absolute paths, as well as context management:
.. code-block::python
with Imported('/path/to/time.py') as time:
time.time()
.. warning::
Each time the `Imported()` is contextualized, it reloads the source code.
Any data saved in the previous instance will get wiped, for the most part.
"""
def __init__(self, path, namespace=None):
if not namespace:
namespace = os.path.splitext(os.path.basename(path))[0]
self.namespace = namespace
self._path = path
self.spec = None
self.imported = None
if namespace in internal.sys.modules:
self.imported = internal.sys.modules[namespace]
self.spec = internal.sys.specs[namespace]
def __repr__(self):
# if self.imported:
# return self.imported.__repr__()
# else:
return f"<loaded-module '{os.path.splitext(os.path.basename(self._path))[0]}' from '{self.path}' (Imported-wrapped)>"
def __enter__(self, *args, **kwargs):
"""
Opens a context to the absolute-module.
Errors are caught and through as a :ref:`~slimHTTP.ModuleError`.
.. warning::
It will re-load the code and thus re-instanciate the memory-space for the module.
So any persistant data or sessions **needs** to be stowewd away between imports.
Session files *(`pickle.dump()`)* is a good option *(or god forbid, `__builtins__['storage'] ...` is an option for in-memory stuff)*.
"""
# import_id = uniqueue_id()
# virtual.sys.modules[absolute_path] = Imported(self.CLIENT_IDENTITY.server, absolute_path, import_id, spec, imported)
# sys.modules[import_id+'.py'] = imported
if not self.spec and not self.imported:
self.spec = internal.sys.specs[self.namespace] = importlib.util.spec_from_file_location(self.namespace, self.path)
self.imported = internal.sys.modules[self.namespace] = importlib.util.module_from_spec(self.spec)
try:
self.spec.loader.exec_module(self.imported)
except Exception as e:
exc_type, exc_obj, exc_tb = sys.exc_info()
fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1]
raise ModuleError(traceback.format_exc(), self.path)
return self.imported
def __exit__(self, *args, **kwargs):
# TODO: https://stackoverflow.com/questions/28157929/how-to-safely-handle-an-exception-inside-a-context-manager
if len(args) >= 2 and args[1]:
if len(args) >= 3:
fname = os.path.split(args[2].tb_frame.f_code.co_filename)[1]
print(f'Fatal error in Imported({self.path}), {fname}@{args[2].tb_lineno}: {args[1]}')
else:
print(args)
@property
def path(self):
return os.path.abspath(self._path)
class ROUTE_HANDLER():
"""
Stub function that will act as a gateway between
@http.<function> and the in-memory route that is stored.
I might be using annotations wrong, but this will store
a route (/url/something) and connect it with a given function
by the programmer.
"""
def __init__(self, route):
self.route = route
self.parser = None
def gateway(self, f):
self.parser = f
class HTTP_RESPONSE():
"""
Forms a HTTP response to the requesting client.
This class is usually used by `GET` or `POST` functions.
Or if a `.py` module is called, the return from the `on_request` function
within the `.py` module could potentially be a `HTTP_RESPONSE`.
slimHTTP is designed to recognize a `HTTP_RESPONSE` object, and automatically
send the `HTTP_RESPONSE.build()` to the end user.
:param headers: Any initial headers to load the response with (optional)
:type headers: dict
:param payload: The payload to supply the user with
:type payload: bytes
"""
def __init__(self, headers={}, payload=b'', *args, **kwargs):
self.headers = headers
self.payload = payload
self.args = args
self.kwargs = kwargs
if not 'ret_code' in self.kwargs: self.kwargs['ret_code'] = 200
self.ret_code_mapper = {200 : b'HTTP/1.1 200 OK\r\n',
204 : b'HTTP/1.1 204 No Content\r\n',
206 : b'HTTP/1.1 206 Partial Content\r\n',
301 : b'HTTP/1.0 301 Moved Permanently\r\n',
307 : b'HTTP/1.1 307 Temporary Redirect\r\n',
302 : b'HTTP/1.1 302 Found\r\n',
404 : b'HTTP/1.1 404 Not Found\r\n',
418 : b'HTTP/1.0 I\'m a teapot\r\n'}
def build_headers(self, additional_headers={}):
x = b''
if 'ret_code' in self.kwargs and self.kwargs['ret_code'] in self.ret_code_mapper:
x += self.ret_code_mapper[self.kwargs['ret_code']]
else:
return b'HTTP/1.1 500 Internal Server Error\r\n\r\n'
if not 'content-length' in [key.lower() for key in self.headers.keys()]:
self.headers['Content-Length'] = str(len(self.payload))
for key, val in {**self.headers, **additional_headers}.items():
if type(key) != bytes: key = bytes(key, 'UTF-8')
if type(val) != bytes: val = bytes(val, 'UTF-8')
x += key + b': ' + val + b'\r\n'
return x + b'\r\n'
def clean_payload(self):
tmp = {k.lower(): v for k,v in self.headers.items()}
if 'content-type' in tmp and tmp['content-type'] == 'application/json' and type(self.payload) not in (bytes, str):
self.payload = json.dumps(self.payload)
if type(self.payload) != bytes:
self.payload = bytes(self.payload, 'UTF-8') # TODO: Swap UTF-8 for a configurable encoding..
def build(self):
self.clean_payload()
ret = self.build_headers()
ret += self.payload
return ret
class HTTP_SERVER():
"""
HTTP_SERVER is normally instanciated with :py:meth:`slimhttpd.host` which would
safely spin up a HTTP / HTTPS server with all the correct arguments.
In case of manual control, this class is the main server instance in charge
of keeping the `"addr":port` open and accepting new connections. It contains a main
event loop, which can be polled in order to accept new clients.
It's also in charge of polling client identities for new events and lift them up
to the caller of :py:func:`slimhttpd.HTTP_SERVER.poll`.
"""
def __init__(self, *args, **kwargs):
"""
`__init__` takes ambigious arguments through `**kwargs`.
They are passed down to `HTTP_SERVER.config` transparently and used later.
Some values are used upon `__init__` however, since they are part of the
initiation process, those arguments are:
:param addr: Address to listen on, default `0.0.0.0`.
:type addr: str
:param port: Port to listen on, default `80` unless HTTPS mode, in which case default is `443`.
:type port: int
"""
self.default_port = 80
if not 'port' in kwargs: kwargs['port'] = self.default_port
if not 'addr' in kwargs: kwargs['addr'] = ''
self.config = {**self.default_config(), **kwargs}
self.allow_list = None
## If config doesn't pass inspection, raise the error message given by check_config()
if (config_error := self.check_config(self.config)) is not True:
raise config_error
self.sockets = {}
self.streams = {}
self.setup_socket()
self.main_sock_fileno = self.sock.fileno()
self.pollobj = epoll()
self.pollobj.register(self.main_sock_fileno, EPOLLIN)
self.sock.listen(10)
self.upgraders = {}
self.on_upgrade_pre_func = None
self.methods = {
b'GET' : self.GET_func
}
self.routes = {
None : {} # Default vhost routes
}
self.debuggable_routes = {}
# while drop_privileges() is None:
# log('Waiting for privileges to drop.', once=True, level=5, origin='slimHTTP', function='http_serve')
def is_debuggable(self, url :str):
if len(self.debuggable_routes) == 0:
return True
if url in self.debuggable_routes:
return True
return False
def debug(self, url):
self.debuggable_routes[url] = True
def setup_socket(self):
self.sock = socket()
self.sock.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
try:
self.sock.bind((self.config['addr'], self.config['port']))
self.log(f"Bound to {self.config['addr']}:{self.config['port']}")
except:
raise slimHTTP_Error(f'Address already in use: {":".join((self.config["addr"], str(self.config["port"])))}')
def log(self, *args, **kwargs):
"""
A simple print wrapper, placeholder for more advanced logging in the future.
Joins any `*args` together and safely calls :func:'str' on each argument.
"""
logger = logging.getLogger(__name__)
if 'level' in kwargs:
if type(kwargs['level']) == str:
if kwargs['level'].lower() == 'critical':
kwargs['level'] = logging.CRITICAL
elif kwargs['level'].lower() == 'erro':
kwargs['level'] = logging.ERROR
elif kwargs['level'].lower() == 'warning':
kwargs['level'] = logging.WARNING
elif kwargs['level'].lower() == 'info':
kwargs['level'] = logging.INFO
elif kwargs['level'].lower() == 'debug':
kwargs['level'] = logging.DEBUG
# elif kwargs['level'].lower() == 'notset':
# kwargs['level'] = logging.NOTSET
elif type(kwargs['level']) == int:
if not kwargs['level'] in (0, 10, 20, 30, 40, 50):
raise LoggerError(f"Unable to automatically detect the correct log level for: {args} | {kwargs}")
else:
raise LoggerError(f"Unknown level definition: {kwargs['level']}")
else:
kwargs['level'] = logging.INFO
logger.log(kwargs['level'], ''.join([str(x) for x in args]))
# TODO: Dump raw requests/logs to a .pcap: (Optional, if scapy is precent)
#
# from scapy.all import wrpcap, Ether, IP, UDP
# packet = Ether() / IP(dst="1.2.3.4") / UDP(dport=123)
# wrpcap('foo.pcap', [packet])
def check_config(self, conf):
"""
Makes sure that the given configuration *(either upon startup via `**kwargs` or
during annotation override of configuration (`@http.configuration`))* is correct.
#TODO: Verify that 'proxy' mode endpoints aren't ourself, because that **will** hand slimHTTP. (https://github.com/Torxed/slimHTTP/issues/11)
:param conf: Dictionary representing a valid configuration. #TODO: Add a doc on documentation :P
:type conf: dict
"""
if not 'web_root' in conf: return ConfError('Missing "web_root" in configuration.')
if not 'index' in conf: return ConfError('Missing "index" in configuration.')
if not 'port' in conf: conf['port'] = self.default_port
if not 'addr' in conf: conf['addr'] = ''
if 'vhosts' in conf:
for host in conf['vhosts']:
if 'proxy' in conf['vhosts'][host]:
if not ':' in conf['vhosts'][host]['proxy']: return ConfError(f'Missing port number in proxy definition for vhost {host}: "{conf["vhosts"][host]["proxy"]}"')
continue
if 'module' in conf['vhosts'][host]:
if not os.path.isfile(conf['vhosts'][host]['module']): return ConfError(f"Missing module for vhost {host}: {os.path.abspath(conf['vhosts'][host]['module'])}")
if not os.path.splitext(conf['vhosts'][host]['module'])[1] == '.py': return ConfError(f"vhost {host}'s module is not a python module: {conf['vhosts'][host]['module']}")
continue
if not 'web_root' in conf['vhosts'][host]: return ConfError(f'Missing "web_root" in vhost {host}\'s configuration.')
if not 'index' in conf['vhosts'][host]: return ConfError(f'Missing "index" in vhost {host}\'s configuration.')
return True
def unregister(self, identity):
"""
Unregisters a :py:class:`slimhttpd.HTTP_CLIENT_IDENTITY` s socket by calling `self.pollobj.unregister`
on the client identity socket fileno.
:param identity: Any valid `*_CLIENT_IDENTITY` handler.
:type identity: :py:class:`slimhttpd.HTTP_CLIENT_IDENTITY` or :py:class:`spiderWeb.WS_CLIENT_IDENTITY`
"""
self.pollobj.unregister(identity.fileno)
def default_config(self):
"""
Returns a simple but sane default configuration in case no one is given.
Defaults to hosting the `web_root` to the `/srv/http` folder.
:return: {'web_root' : '/srv/http', 'index' : 'index.html', 'vhosts' : { }, 'port' : 80}
:rtype: dict
"""
return {
'web_root' : '/srv/http',
'index' : 'index.html',
'vhosts' : {
},
'port' : 80
}
def configuration(self, config=None, *args, **kwargs):
"""
A decorator which can be set with a `@http.configuration` annotation as well as directly called.
Using the decorator leaves some room for processing configuration before being returned
to this function, in cases where configuration-checks needs to be isolated to a function
in order to make the code neat.::
@app.configuration
def config():
return {
"web_root" : "./web-root",
"index" : "index.html"
}
.. warning::
The following hook would be called after socket setup.
There is there for no point in adding `addr` or `port` to this configuration as the socket
layer has already been set up.
:param config: Dictionary representing a valid configuration which will be checked with :py:func:`slimhttpd.HTTP_SERVER.check_config`.
:type config: dict
"""
# TODO: Merge instead of replace config?
if type(config) == dict:
self.config = config
elif config:
staging_config = config(instance=self)
if self.check_config(staging_config) is True:
self.config = staging_config
def GET(self, f, *args, **kwargs):
self.methods[b'GET'] = f
def GET_func(self, request):
"""
The built-in `GET` function for slimHTTP.
This can be overridden with `@http.GET`.
It serves static files under whatever configuration was given on startup.
As well as support `.py` file handling.
:param request: The current request from the end user
:type request: :ref:`~slimHTTP.HTTP_REQUEST`
:return: The contents of the file in byte-representation
:rtype: bytes
"""
# Join the web_root with the requested URL safely(?) passed through os.path.abspath() removing the initial / or C:\ part.
try:
path = os.path.safepath(request.web_root, request.headers[b'URL'])
except:
request.ret_code = 404
return
extension = os.path.splitext(path)[1]
# Only allow .py files marked as executable to execute as a module.
# This to avoid .py files intended for downloads to be executed.
if extension == '.py' and os.access(path, os.X_OK):
if isfile(path):
try:
loaded_module = Imported(path)
request.CLIENT_IDENTITY.server.log(f'Routing {request.CLIENT_IDENTITY}\'s GET request to {loaded_module} @ {request.vhost}"')
with loaded_module as module:
# Double-check so that the imported module didn't inject something
# into the route options for the specific vhost.
if request.vhost in request.CLIENT_IDENTITY.server.routes and request.headers[b'URL'] in request.CLIENT_IDENTITY.server.routes[request.vhost]:
return request.CLIENT_IDENTITY.server.routes[request.vhost][request.headers[b'URL']].parser(request)
elif hasattr(module, 'on_request'):
return module.on_request(request)
except ModuleError as e:
print(e.message)
request.CLIENT_IDENTITY.close()
else:
request.ret_code = 404
return
else:
## We're dealing with a normal, non .py file.
F_OBJ = FILE(request, path)
file_size = bytes(str(F_OBJ.size), 'UTF-8')
if b'range' in request.headers:
_, data_range = request.headers[b'range'].split(b'=',1)
start, stop = data_range.split(b'-', 1)
start = int(start.decode('UTF-8'))
if len(stop) == 0:
stop = file_size
chunksize = 8192
else:
stop = int(stop.decode('UTF-8'))
chunksize = min(stop-start, 8192)
request.response_headers[b'Content-Range'] = bytes(f'bytes {start}-{stop}/{file_size}', 'UTF-8')
F_OBJ = STREAM_CHUNKED(request, path, start, chunksize=chunksize)
elif F_OBJ.mime == 'application/octet-stream':
## TODO: Not tested