Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 4643a44
Showing
8 changed files
with
514 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
Canister | ||
======== | ||
|
||
Canister is a simple wrapper around bottle, providing: | ||
|
||
- file based configuration | ||
- logging | ||
- sessions (server side, based on a `session_id` cookie) | ||
- ssl support (for gevent or cherrypy as server adapter at least) | ||
- can serve all files in a `static_path` directory out of the box | ||
- CORS for cross-domain REST APIs | ||
- authentication through basic auth or bearer token (oauth2) | ||
|
||
### Usage | ||
|
||
``` | ||
import canister | ||
import bottle | ||
can = canister.build('example.config') | ||
@can.get('/') | ||
def index(): | ||
if can.session.user: | ||
return "Hi " + str(can.session.user) + "!"; | ||
else: | ||
err = bottle.HTTPError(401, "Login required") | ||
err.add_header('WWW-Authenticate', 'Basic realm="%s"' % 'private') | ||
return err | ||
@can.get('/hello/<name>') | ||
def hello(name): | ||
can.log.info('Hey!') | ||
time.sleep(0.1) | ||
can.log.info('Ho!') | ||
return "Hello {0}!".format(name) #template('<b>Hello {{name}}</b>!', name=name) | ||
can.run() | ||
``` | ||
|
||
### Logs | ||
|
||
### Sessions | ||
|
||
### Authentication | ||
|
||
### SSL | ||
|
||
### Serving a directory | ||
|
||
### Websockets | ||
|
||
### CORS |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,179 @@ | ||
""" | ||
This is a wrapper around bottle, providing: | ||
- file based configuration | ||
- logging | ||
- sessions (server side) based on a `session_id` cookie | ||
- ssl support (for gevent or cherrypy as server adapter at least) | ||
- serves all files in the `static_path` directory out of the box | ||
- CORS for cross-domain REST APIs | ||
- authentication through basic auth or bearer token | ||
""" | ||
import sys | ||
import logging | ||
import logging.handlers | ||
import bottle | ||
import threading | ||
import base64 | ||
import os | ||
import os.path | ||
import configparser | ||
import jwt | ||
import hashlib | ||
|
||
def getLogger(level, path, days, notify, **kwargs): | ||
log = logging.getLogger('canister') | ||
if level.upper() != 'DISABLED': | ||
os.makedirs(path, exist_ok=True) | ||
log.setLevel(level) | ||
h = logging.handlers.TimedRotatingFileHandler( os.path.join(path, 'log'), when='midnight', backupCount=int(days)) | ||
f = logging.Formatter('%(asctime)s %(levelname)-8s [%(threadName)s] %(message)s') | ||
h.setFormatter( f ) | ||
log.addHandler( h ) | ||
if notify: | ||
pass | ||
# TODO: add email notifier with SMTPHandler + QueueHandler | ||
return log | ||
|
||
|
||
def getRequestsLogger(level, path, days, requests, **kwargs): | ||
log = logging.getLogger('requests') | ||
if level.upper() != 'DISABLED' and requests.lower() == 'true': | ||
log.setLevel(level) | ||
h = logging.handlers.TimedRotatingFileHandler( os.path.join(path, 'requests'), when='midnight', backupCount=int(days)) | ||
log.addHandler( h ) | ||
return log | ||
|
||
def build(config_path): | ||
|
||
config = configparser.ConfigParser() | ||
with open(config_path) as f: | ||
config.read_file(f) | ||
|
||
can = bottle.Bottle() | ||
|
||
|
||
log = getLogger(**config['logs']) | ||
log_req = getRequestsLogger(**config['logs']) | ||
|
||
log.info('============') | ||
log.info('Initializing') | ||
log.info('============') | ||
|
||
log.info('python version: ' + sys.version) | ||
log.info('bottle version: ' + bottle.__version__) | ||
|
||
log.info('------------------------------------------') | ||
for s in config.sections(): | ||
for p in config[s]: | ||
log.info('%-23s = %s' % (s + '.' + p, config[s][p]) ) | ||
log.info('------------------------------------------') | ||
|
||
|
||
sessions = {} | ||
session_secret = base64.b64encode(os.urandom(30)).decode('ascii') | ||
can.session = threading.local() | ||
|
||
auth_basic_username = config.get('auth_basic', 'username', fallback=None) | ||
auth_basic_password = config.get('auth_basic', 'password', fallback=None) | ||
auth_basic_enc = config.get('auth_basic', 'password_enc', fallback='clear').lower() | ||
|
||
if auth_basic_username and auth_basic_password: | ||
log.info('Enabling basic authentication for: ' + auth_basic_username) | ||
|
||
auth_jwt_secret = config.get('auth_jwt', 'secret', fallback=None) | ||
|
||
if auth_jwt_secret: | ||
log.info('Enabling JWT bearer token authentication') | ||
auth_jwt_secret = base64.urlsafe_b64decode(auth_jwt_secret) | ||
|
||
def getUser(auth): | ||
tokens = auth.split() | ||
if len(tokens) != 2: | ||
log.warning('Invalid or unsupported Authorization header: ' + auth) | ||
return None | ||
|
||
if auth_basic_username and tokens[0].lower() == 'basic': | ||
user, pwd = base64.b64decode( tokens[1] ).decode('utf-8').split(':', 1) | ||
if user != auth_basic_username: | ||
return None | ||
elif auth_basic_enc == 'clear' and auth_basic_password == pwd: | ||
return user | ||
elif auth_basic_enc == 'sha256' and auth_basic_password == hashlib.sha256(pwd): | ||
return user | ||
else: | ||
return None | ||
|
||
elif auth_jwt_secret and tokens[0].lower() == 'bearer': | ||
profile = jwt.decode(tokens[1], auth_jwt_secret) | ||
return profile | ||
|
||
@can.hook('before_request') | ||
def before(): | ||
req = bottle.request | ||
res = bottle.response | ||
|
||
# thread name = <ip>-.... | ||
threading.current_thread().name = can.remote_addr + '-....' | ||
log.info(req.method + ' ' + req.url) | ||
|
||
can.session.id = None | ||
can.session.user = None | ||
can.session.data = None | ||
|
||
can.session.id = req.get_cookie('session_id', secret=session_secret) | ||
if not can.session.id or (can.session.id not in sessions): | ||
# either there is no ID or it's been purged from the sessions | ||
can.session.id = base64.b64encode(os.urandom(18)).decode('ascii') | ||
res.set_cookie('session_id', can.session.id, secret=session_secret, httponly=True) | ||
sessions[can.session.id] = {} | ||
|
||
# thread name = <ip>-<session_id[0:4]> | ||
threading.current_thread().name = can.remote_addr + '-' + can.session.id[0-4] | ||
log.info('Session id: ' + can.session.id) | ||
|
||
# set data | ||
can.session.data = sessions[can.session.id] | ||
|
||
auth = req.headers.get('Authorization') | ||
if auth: | ||
try: | ||
log.debug('Attempting authentication for: ' + auth) | ||
user = getUser(auth) | ||
can.session.user = user | ||
if user: | ||
log.info('Logged in as: ' + user) | ||
except Exception as ex: | ||
log.warning('Authentication failed: ' + str(ex)) | ||
|
||
|
||
|
||
|
||
@can.hook('after_request') | ||
def after(): | ||
res = bottle.response | ||
# TODO: is there a way to log the start of the response? | ||
log.info(res.status_line) | ||
|
||
|
||
def logout(user): | ||
# TODO: remove cookie | ||
# TODO: clear session | ||
pass | ||
|
||
def run(): | ||
|
||
static_path = config.get('views', 'static_path', fallback=None) | ||
if static_path: | ||
@can.get('/<path:path>') | ||
def serve(path): | ||
return bottle.static_file(path, root=static_path) | ||
|
||
log.info('Starting...') | ||
bottle.run(can, log=log_req, error_log=log, **config['bottle']) | ||
|
||
can.log = log | ||
can.run = run | ||
can.logout = logout | ||
|
||
return can |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,83 @@ | ||
[bottle] | ||
|
||
# Server adapter to use. Choices: wsgiref, gevent, cherrypy... (see bottle docs for full list) | ||
server = wsgiref | ||
|
||
# Pass 0.0.0.0 to listens on all interfaces including the external one. | ||
host = localhost | ||
|
||
# Server port to bind to. Values below 1024 require root privileges. | ||
port = 8080 | ||
|
||
# Traceback on error pages, templates not cached, plugins applied directly | ||
# Also activates reloader, and redirect all logs on DEBUG level to stdout/stderr | ||
debug = true | ||
|
||
[logs] | ||
|
||
# If not in debug mode, 2 logs are produced per day: | ||
# requests.yyyy-mm-dd - contains requests only | ||
# log.yyyy-mm-dd - contains all messages, including requests | ||
# ...and nothing is written to stdout/stderr | ||
|
||
# The logs directory | ||
path = ./logs/ | ||
# Logging levels: DISABLED, DEBUG, INFO, WARNING, ERROR, CRITICAL | ||
level = DEBUG | ||
# Log older than that will be deleted | ||
days = 100 | ||
# Requests / server adapter stdout | ||
requests = true | ||
# (not yet implemented) email to notify on error | ||
notify = | ||
|
||
|
||
[sessions] | ||
|
||
# (not yet implemented) how long the session data will still be available after the last access | ||
expiration = 30d | ||
# (not yet implemented) the interval to check for obsolete sessions | ||
check_interval = 1h | ||
|
||
|
||
[views] | ||
# static path: everything inside will be served like normal files | ||
static_path = ./static/ | ||
|
||
# templates path: used in bottle with template('...') | ||
template_path = ./views/ | ||
|
||
|
||
[requests] | ||
|
||
# applies CORS to responses, write * to allow AJAX requests from anywhere | ||
CORS = false | ||
|
||
# Basic auth | ||
[auth_basic] | ||
|
||
username = alice | ||
password_enc = clear | ||
password = my-secret | ||
|
||
# ...or alternatively, if you dislike putting your plain text password in the config: | ||
# username = alice | ||
# password_enc = sha256 | ||
# password = 186ef76e9d6a723ecb570d4d9c287487d001e5d35f7ed4a313350a407950318e | ||
|
||
|
||
|
||
# Auth using JWT (using auth0 for example) | ||
[auth_jwt] | ||
|
||
# the secret, encoded as urlsafe_b64decode | ||
secret = 1LlIwTCB7wliFKRVaTcWg7Ask6yRGVHjxcZ-ATVCnGTlKnHLpEuhuUuhv34OdFD0 | ||
|
||
#[asodb] | ||
|
||
# if true, read acces will not require authorization | ||
# public = True | ||
# owner | ||
# owner = | ||
# a list of emails of who can edit data | ||
# admins = * |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
#!/usr/bin/env python3 | ||
|
||
# ab -n 1000 -c 10 http://localhost:8080/hello/world | ||
# ab -n 2 -c 2 http://127.0.0.1:8080/hello/world | ||
|
||
import gevent.monkey | ||
gevent.monkey.patch_all() | ||
|
||
import canister | ||
import time | ||
import bottle | ||
|
||
can = canister.build('example.config') | ||
|
||
@can.get('/') | ||
def index(): | ||
# Accessing session data | ||
sdata = can.session.data | ||
if 'counter' in can.session.data: | ||
sdata['counter'] += 1 | ||
else: | ||
sdata['counter'] = 0 | ||
|
||
# Logging something | ||
can.log.info('Hey!') | ||
can.log.warning('Ho!') | ||
|
||
return '<pre>Session id: %s\nCounter: %s</pre>' % (can.session.id, can.session.data['counter']) | ||
|
||
|
||
can.run() |
Oops, something went wrong.