diff --git a/Dockerfile b/Dockerfile index bdc664147..1128bf24d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,8 @@ ENV PUPPETBOARD_SETTINGS docker_settings.py RUN mkdir -p /usr/src/app/ WORKDIR /usr/src/app/ +VOLUME /var/lib/puppetboard + COPY requirements*.txt /usr/src/app/ RUN pip install -r requirements-docker.txt diff --git a/puppetboard/app.py b/puppetboard/app.py index ac60179ab..d170d6a0d 100644 --- a/puppetboard/app.py +++ b/puppetboard/app.py @@ -13,16 +13,22 @@ from flask import ( Flask, render_template, abort, url_for, Response, stream_with_context, redirect, - request, session, jsonify + request, session, jsonify, flash +) +from flask_login import ( + LoginManager, login_required, + login_user, logout_user ) from jinja2.utils import contextfunction from pypuppetdb.QueryBuilder import * -from puppetboard.forms import QueryForm +from puppetboard.forms import QueryForm, LoginForm from puppetboard.utils import (get_or_abort, yield_or_stop, get_db_version) from puppetboard.dailychart import get_daily_reports_chart +from puppetboard.models import db, Users +from sqlalchemy.exc import OperationalError import werkzeug.exceptions as ex import CommonMark @@ -51,6 +57,19 @@ ] app = get_app() +login_manager = LoginManager() +login_manager.init_app(app) +login_manager.login_view = "login" +if not app.config['LOGIN_DISABLED']: + try: + users = Users.query.all() + except OperationalError: + db.create_all() + users = Users.query.all() + if len(users) < 1: + admin_user = Users(username='admin', password='admin123') + db.session.add(admin_user) + db.session.commit() graph_facts = app.config['GRAPH_FACTS'] numeric_level = getattr(logging, app.config['LOGLEVEL'].upper(), None) @@ -88,6 +107,7 @@ def now(format='%m/%d/%Y %H:%M:%S'): @app.route('/', defaults={'env': app.config['DEFAULT_ENVIRONMENT']}) @app.route('//') +@login_required def index(env): """This view generates the index page and displays a set of metrics and latest reports on nodes fetched from PuppetDB. @@ -200,6 +220,7 @@ def index(env): @app.route('/nodes', defaults={'env': app.config['DEFAULT_ENVIRONMENT']}) @app.route('//nodes') +@login_required def nodes(env): """Fetch all (active) nodes from PuppetDB and stream a table displaying those nodes. @@ -285,6 +306,7 @@ def inventory_facts(): @app.route('/inventory', defaults={'env': app.config['DEFAULT_ENVIRONMENT']}) @app.route('//inventory') +@login_required def inventory(env): """Fetch all (active) nodes from PuppetDB and stream a table displaying those nodes along with a set of facts about them. @@ -306,6 +328,7 @@ def inventory(env): @app.route('/inventory/json', defaults={'env': app.config['DEFAULT_ENVIRONMENT']}) @app.route('//inventory/json') +@login_required def inventory_ajax(env): """Backend endpoint for inventory table""" draw = int(request.args.get('draw', 0)) @@ -344,6 +367,7 @@ def inventory_ajax(env): @app.route('/node/', defaults={'env': app.config['DEFAULT_ENVIRONMENT']}) @app.route('//node/') +@login_required def node(env, node_name): """Display a dashboard for a node showing as much data as we have on that node. This includes facts and reports but not Resources as that is too @@ -378,6 +402,7 @@ def node(env, node_name): @app.route('/reports/', defaults={'env': app.config['DEFAULT_ENVIRONMENT']}) @app.route('//reports/') +@login_required def reports(env, node_name): """Query and Return JSON data to reports Jquery datatable @@ -401,6 +426,7 @@ def reports(env, node_name): @app.route('/reports//json', defaults={'env': app.config['DEFAULT_ENVIRONMENT']}) @app.route('//reports//json') +@login_required def reports_ajax(env, node_name): """Query and Return JSON data to reports Jquery datatable @@ -509,6 +535,7 @@ def reports_ajax(env, node_name): @app.route('/report//', defaults={'env': app.config['DEFAULT_ENVIRONMENT']}) @app.route('//report//') +@login_required def report(env, node_name, report_id): """Displays a single report including all the events associated with that report and their status. @@ -560,6 +587,7 @@ def report(env, node_name, report_id): @app.route('/facts', defaults={'env': app.config['DEFAULT_ENVIRONMENT']}) @app.route('//facts') +@login_required def facts(env): """Displays an alphabetical list of all facts currently known to PuppetDB. @@ -609,6 +637,7 @@ def facts(env): @app.route('/fact//', defaults={'env': app.config['DEFAULT_ENVIRONMENT']}) @app.route('//fact//') +@login_required def fact(env, fact, value): """Fetches the specific fact(/value) from PuppetDB and displays per node for which this fact is known. @@ -655,6 +684,7 @@ def fact(env, fact, value): 'fact': None, 'value': None}) @app.route('//node//facts/json', defaults={'fact': None, 'value': None}) +@login_required def fact_ajax(env, node, fact, value): """Fetches the specific facts matching (node/fact/value) from PuppetDB and return a JSON table @@ -747,6 +777,7 @@ def fact_ajax(env, node, fact, value): @app.route('/query', methods=('GET', 'POST'), defaults={'env': app.config['DEFAULT_ENVIRONMENT']}) @app.route('//query', methods=('GET', 'POST')) +@login_required def query(env): """Allows to execute raw, user created querries against PuppetDB. This is currently highly experimental and explodes in interesting ways since none @@ -792,6 +823,7 @@ def query(env): @app.route('/metrics', defaults={'env': app.config['DEFAULT_ENVIRONMENT']}) @app.route('//metrics') +@login_required def metrics(env): """Lists all available metrics that PuppetDB is aware of. @@ -812,6 +844,7 @@ def metrics(env): @app.route('/metric/', defaults={'env': app.config['DEFAULT_ENVIRONMENT']}) @app.route('//metric/') +@login_required def metric(env, metric): """Lists all information about the metric of the given name. @@ -839,6 +872,7 @@ def metric(env, metric): @app.route('/catalogs/compare/', defaults={'env': app.config['DEFAULT_ENVIRONMENT']}) @app.route('//catalogs/compare/') +@login_required def catalogs(env, compare): """Lists all nodes with a compiled catalog. @@ -867,6 +901,7 @@ def catalogs(env, compare): @app.route('/catalogs/compare//json', defaults={'env': app.config['DEFAULT_ENVIRONMENT']}) @app.route('//catalogs/compare//json') +@login_required def catalogs_ajax(env, compare): """Server data to catalogs as JSON to Jquery datatables """ @@ -926,6 +961,7 @@ def catalogs_ajax(env, compare): @app.route('/catalog/', defaults={'env': app.config['DEFAULT_ENVIRONMENT']}) @app.route('//catalog/') +@login_required def catalog_node(env, node_name): """Fetches from PuppetDB the compiled catalog of a given node. @@ -950,6 +986,7 @@ def catalog_node(env, node_name): @app.route('/catalogs/compare/...', defaults={'env': app.config['DEFAULT_ENVIRONMENT']}) @app.route('//catalogs/compare/...') +@login_required def catalog_compare(env, compare, against): """Compares the catalog of one node, parameter compare, with that of with that of another node, parameter against. @@ -978,6 +1015,7 @@ def catalog_compare(env, compare, against): @app.route('/radiator', defaults={'env': app.config['DEFAULT_ENVIRONMENT']}) @app.route('//radiator') +@login_required def radiator(env): """This view generates a simplified monitoring page akin to the radiator view in puppet dashboard @@ -1077,6 +1115,7 @@ def radiator(env): @app.route('/daily_reports_chart.json', defaults={'env': app.config['DEFAULT_ENVIRONMENT']}) @app.route('//daily_reports_chart.json') +@login_required def daily_reports_chart(env): """Return JSON data to generate a bar chart of daily runs. @@ -1106,6 +1145,41 @@ def offline_static(filename): status=200, mimetype=mimetype) +@app.route("/login", methods=["GET", "POST"]) +def login(): + form = LoginForm(meta={ + 'csrf_secret': app.config['SECRET_KEY'], + 'csrf_context': session}) + if form.validate_on_submit(): + user = Users.query.filter_by(username=form.username.data).first() + if user and user.password == form.password.data: + login_user(user, remember=form.remember.data) + return redirect(url_for('index')) + else: + flash('Login failed.', 'error') + return render_template('login.html', form=form) + + +@app.route("/users") +@login_required +def users(): + users = Users.query.all() + return render_template('users.html', users=users) + + +@app.route("/logout") +@login_required +def logout(): + logout_user() + flash('You have been logged out.', 'info') + return redirect(url_for('login')) + + +@login_manager.user_loader +def load_user(user_id): + return Users.query.filter_by(id=int(user_id)).first() + + @app.route('/status') def health_status(): return 'OK' diff --git a/puppetboard/default_settings.py b/puppetboard/default_settings.py index e8f3a406b..1bb145359 100644 --- a/puppetboard/default_settings.py +++ b/puppetboard/default_settings.py @@ -8,6 +8,9 @@ PUPPETDB_CERT = None PUPPETDB_TIMEOUT = 20 DEFAULT_ENVIRONMENT = 'production' +LOGIN_DISABLED = True +SQLALCHEMY_DATABASE_URI = 'sqlite:////tmp/puppetboard.db' +SQLALCHEMY_TRACK_MODIFICATIONS = False SECRET_KEY = os.urandom(24) DEV_LISTEN_HOST = '127.0.0.1' DEV_LISTEN_PORT = 5000 diff --git a/puppetboard/docker_settings.py b/puppetboard/docker_settings.py index f137c6aa9..5c7e90a00 100644 --- a/puppetboard/docker_settings.py +++ b/puppetboard/docker_settings.py @@ -16,6 +16,9 @@ PUPPETDB_CERT = os.getenv('PUPPETDB_CERT', None) PUPPETDB_PROTO = os.getenv('PUPPETDB_PROTO', None) PUPPETDB_TIMEOUT = int(os.getenv('PUPPETDB_TIMEOUT', '20')) +LOGIN_DISABLED = os.getenv('LOGIN_DISABLED', True) +SQLALCHEMY_DATABASE_URI = os.getenv('SQLALCHEMY_DATABASE_URI', + 'sqlite:////var/lib/puppetboard/database.db') DEFAULT_ENVIRONMENT = os.getenv('DEFAULT_ENVIRONMENT', 'production') SECRET_KEY = os.getenv('SECRET_KEY', os.urandom(24)) DEV_LISTEN_HOST = os.getenv('DEV_LISTEN_HOST', '127.0.0.1') diff --git a/puppetboard/forms.py b/puppetboard/forms.py index b67d441c7..74b7cf901 100644 --- a/puppetboard/forms.py +++ b/puppetboard/forms.py @@ -4,7 +4,8 @@ from flask_wtf import FlaskForm from wtforms import ( HiddenField, RadioField, SelectField, - TextAreaField, BooleanField, validators + TextAreaField, BooleanField, StringField, + PasswordField, validators ) @@ -28,3 +29,10 @@ class QueryForm(FlaskForm): ('pql', 'PQL'), ]) rawjson = BooleanField('Raw JSON') + + +class LoginForm(FlaskForm): + """The form used to login to Puppetboard""" + username = StringField('Username', [validators.DataRequired(message='Username is required')]) + password = PasswordField('Password', [validators.DataRequired(message='Password is required')]) + remember = BooleanField('Remember me') diff --git a/puppetboard/models.py b/puppetboard/models.py new file mode 100644 index 000000000..d9344ae32 --- /dev/null +++ b/puppetboard/models.py @@ -0,0 +1,31 @@ +from puppetboard.core import get_app +from flask_sqlalchemy import SQLAlchemy +import datetime + + +app = get_app() +db = SQLAlchemy(app) + + +class Users(db.Model): + created = db.Column(db.DateTime, default=datetime.datetime.now, nullable=False) + modified = db.Column(db.DateTime, default=datetime.datetime.now, + onupdate=datetime.datetime.now, nullable=False) + id = db.Column(db.Integer, unique=True, primary_key=True, nullable=False) + username = db.Column(db.String(80), unique=True, nullable=False) + password = db.Column(db.String(80), nullable=False) + + def __repr__(self): + return '{}/{}/{}'.format(self.id, self.username, self.password) + + def is_authenticated(self): + return True + + def is_active(self): + return True + + def is_anonymous(self): + return False + + def get_id(self): + return self.id diff --git a/puppetboard/static/css/puppetboard.css b/puppetboard/static/css/puppetboard.css index 711980583..596638402 100644 --- a/puppetboard/static/css/puppetboard.css +++ b/puppetboard/static/css/puppetboard.css @@ -124,6 +124,24 @@ h1.ui.header.no-margin-bottom { color: #FFF; } +.ui.toggle.checkbox input:focus:checked ~ .box:before, .ui.toggle.checkbox input:focus:checked ~ label:before { + background-color: #2C3E50 !important; +} + +.ui.button.darkblue { + background-color: #2C3E50; + border-color: #2C3E50; + color: #FFF; +} + +.ui.button.darkblue:hover { + background-color: #2C3E50DB; +} + +.inline.field.left { + text-align: left; +} + .ui.menu.yellow { background-color: #F0E965; } diff --git a/puppetboard/templates/layout.html b/puppetboard/templates/layout.html index 5c740a2ba..5375496a6 100644 --- a/puppetboard/templates/layout.html +++ b/puppetboard/templates/layout.html @@ -89,7 +89,28 @@ {% endfor %} - + {%- if current_user.is_authenticated -%} + + {%- endif -%} +
diff --git a/puppetboard/templates/login.html b/puppetboard/templates/login.html new file mode 100644 index 000000000..700ffa890 --- /dev/null +++ b/puppetboard/templates/login.html @@ -0,0 +1,132 @@ + + + + + + + + + {{config.PAGE_TITLE}} + {% if config.OFFLINE_MODE %} + + + + {% else %} + + + + {% endif %} + + + + {% if config.OFFLINE_MODE %} + + + + {% else %} + + + + {% endif %} + + + + + + +
+
+

+
+ Log-in to Puppetboard +
+

+
+ {{ form.csrf_token }} +
+
+
+ + {{ form.username(placeholder='Username') }} +
+
+
+
+ + {{ form.password(placeholder='Password') }} +
+
+
+
+ {{ form.remember }} + {{ form.remember.label }} +
+
+
Login
+
+ +
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + {% endwith %} +
+
+ + + + diff --git a/puppetboard/templates/users.html b/puppetboard/templates/users.html new file mode 100644 index 000000000..b22c7cbf3 --- /dev/null +++ b/puppetboard/templates/users.html @@ -0,0 +1,36 @@ +{% extends 'layout.html' %} +{% import '_macros.html' as macros %} +{% block content %} +
+ +
+
+ +
+ + + + + + + + + + + {% for user in users %} + + + + + + + {% endfor %} + +
UsernameCreatedModified 
{{ user.username }}{{ user.created.strftime('%Y-%m-%d %H:%M:%S') }}{{ user.modified.strftime('%Y-%m-%d %H:%M:%S') }} + + +
+{% endblock content %} diff --git a/requirements.txt b/requirements.txt index 4d3c83491..d93d2fd4f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,7 @@ Flask >=0.12 Flask-WTF >=0.14.2 +Flask-Login >=0.4.1 +Flask-SQLAlchemy >=2.3.2 Jinja2 >=2.9.5 MarkupSafe >=0.19 WTForms >=2.1