From 56d659e232eefbcd416fc0d6f8f224ca7f630da2 Mon Sep 17 00:00:00 2001 From: Matt Vitale Date: Mon, 2 Apr 2018 14:40:36 -0400 Subject: [PATCH 01/17] Fix urlopen import. Fix filter not returning a list object in Python3. Fix urlopen usage in Python 3 when checking for updated version online. --- app/__init__.py | 8 +-- .../device_definitions/cisco/cisco_ios.py | 2 +- app/scripts_bank/lib/functions.py | 56 ++++++++++++------- 3 files changed, 40 insertions(+), 26 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index d50c035..d0f9639 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,12 +1,12 @@ import os +from celery import Celery from flask import Flask from flask_sqlalchemy import SQLAlchemy from flask_bootstrap import Bootstrap from flask_script import Manager -from data_handler import DataHandler -from log_handler import LogHandler -from ssh_handler import SSHHandler -from celery import Celery +from .data_handler import DataHandler +from .log_handler import LogHandler +from .ssh_handler import SSHHandler app = Flask(__name__, instance_relative_config=True) diff --git a/app/device_classes/device_definitions/cisco/cisco_ios.py b/app/device_classes/device_definitions/cisco/cisco_ios.py index f9dceab..f746e2d 100644 --- a/app/device_classes/device_definitions/cisco/cisco_ios.py +++ b/app/device_classes/device_definitions/cisco/cisco_ios.py @@ -109,7 +109,7 @@ def pull_interface_mac_addresses(self, activeSession): # Split line on commas x = line.split(',') # Remove empty fields from string, specifically if first field is empty (1-2 digit vlan causes this) - x = filter(None, x) + x = list(filter(None, x)) if x: y = {} y['vlan'] = x[0].strip() diff --git a/app/scripts_bank/lib/functions.py b/app/scripts_bank/lib/functions.py index 97cbf59..e78a52c 100755 --- a/app/scripts_bank/lib/functions.py +++ b/app/scripts_bank/lib/functions.py @@ -4,7 +4,7 @@ try: from urllib import urlopen # Python 2 except ImportError: - from urllib.parse import urlopen # Python 3 + from urllib.request import urlopen # Python 3 class UserCredentials(object): @@ -64,32 +64,46 @@ def isInteger(x): def checkForVersionUpdate(config): """Check for NetConfig updates on GitHub.""" try: + # with urlopen(config['GH_MASTER_BRANCH_URL']) as a: + # masterConfig = a.read().decode('utf-8') masterConfig = urlopen(config['GH_MASTER_BRANCH_URL']) - except IOError: + masterConfig = masterConfig.read().decode('utf-8') + # Reverse lookup as the VERSION variable should be close to the bottom + if masterConfig: + for x in masterConfig.splitlines(): + if 'VERSION' in x: + x = x.split('=') + try: + # Strip whitespace and single quote mark + masterVersion = x[-1].strip().strip("'") + except IndexError: + continue + # Verify if current version matches most recent GitHub release + if masterVersion != config['VERSION']: + # Return False if the current version does not match the most recent version + return jsonify(status="False", masterVersion=masterVersion) + # If VERSION can't be found, successfully compared, or is identical, just return True + return jsonify(status="True") + else: + # Error when accessing URL. Default to True + return "True" + except IOError as e: # Catch exception if unable to access URL, or access to internet is blocked/down. Default to True return "True" - # Reverse lookup as the VERSION variable should be close to the bottom - for x in masterConfig: - if 'VERSION' in x: - x = x.split('=') - try: - # Strip whitespace and single quote mark - masterVersion = x[-1].strip().strip("'") - except IndexError: - continue - # Verify if current version matches most recent GitHub release - if masterVersion != config['VERSION']: - # Return False if the current version does not match the most recent version - return jsonify(status="False", masterVersion=masterVersion) - # If VERSION can't be found, successfully compared, or is identical, just return True - return jsonify(status="True") + except Exception as e: + # Return True for all other exceptions + return "True" + # Get current timestamp for when starting a script def getCurrentTime(): - currentTime = datetime.now() - return currentTime + """Get current timestamp.""" + currentTime = datetime.now() + return currentTime + # Returns time elapsed between current time and provided time in 'startTime' def getScriptRunTime(startTime): - endTime = getCurrentTime() - startTime - return endTime \ No newline at end of file + """Calculate time elapsed since startTime was first measured.""" + endTime = getCurrentTime() - startTime + return endTime From 8904ce783b608a0f1ade7a516dc9afbf007b5f79 Mon Sep 17 00:00:00 2001 From: Matt Vitale Date: Tue, 3 Apr 2018 11:42:52 -0400 Subject: [PATCH 02/17] Add errors blueprint --- app/__init__.py | 4 ++++ app/errors/__init__.py | 5 +++++ app/errors/handlers.py | 14 ++++++++++++++ app/views.py | 12 ------------ 4 files changed, 23 insertions(+), 12 deletions(-) create mode 100644 app/errors/__init__.py create mode 100644 app/errors/handlers.py diff --git a/app/__init__.py b/app/__init__.py index d0f9639..ea51fdc 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -28,6 +28,10 @@ celery = Celery(app.name, broker=app.config['CELERY_BROKER_URL'], backend=app.config['CELERY_RESULT_BACKEND']) celery.conf.update(app.config) +# Blueprints +from app.errors import bp as errors_bp +app.register_blueprint(errors_bp) + from app import views, models manager = Manager(app) diff --git a/app/errors/__init__.py b/app/errors/__init__.py new file mode 100644 index 0000000..5701c1d --- /dev/null +++ b/app/errors/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('errors', __name__) + +from app.errors import handlers diff --git a/app/errors/handlers.py b/app/errors/handlers.py new file mode 100644 index 0000000..38effd1 --- /dev/null +++ b/app/errors/handlers.py @@ -0,0 +1,14 @@ +from flask import render_template +from app.errors import bp + + +@bp.app.errorhandler(404) +def not_found_error(error): + """Return 404 page on 404 error.""" + return render_template('errors/404.html', error=error), 404 + + +@bp.app.errorhandler(500) +def handle_500(error): + """Return 500 page on 500 error.""" + return render_template('errors/500.html', error=error), 500 diff --git a/app/views.py b/app/views.py index e351b32..bf9c6e8 100644 --- a/app/views.py +++ b/app/views.py @@ -92,18 +92,6 @@ def before_request(): ######################## -@app.errorhandler(404) -def not_found_error(error): - """Return 404 page on 404 error.""" - return render_template('errors/404.html', error=error), 404 - - -@app.errorhandler(500) -def handle_500(error): - """Return 500 page on 500 error.""" - return render_template('errors/500.html', error=error), 500 - - @app.route('/nohostconnect/') @app.route('/errors/nohostconnect/') def noHostConnectError(host): From 46b78387f10cf0a542233222fa2bf273190b6955 Mon Sep 17 00:00:00 2001 From: Matt Vitale Date: Tue, 3 Apr 2018 14:38:20 -0400 Subject: [PATCH 03/17] Added auth blueprint. Fixed errors BP. Cleaned up login page and logic. --- app/__init__.py | 6 +++- app/auth/__init__.py | 5 +++ app/auth/forms.py | 11 ++++++ app/auth/login.py | 0 app/auth/routes.py | 49 +++++++++++++++++++++++++ app/errors/handlers.py | 4 +-- app/forms.py | 7 ---- app/templates/{ => auth}/login.html | 11 +++++- app/templates/base.html | 2 +- app/templates/errors/notloggedin.html | 4 +-- app/templates/inc/navbar-menu.html | 4 +-- app/views.py | 52 +++------------------------ 12 files changed, 92 insertions(+), 63 deletions(-) create mode 100644 app/auth/__init__.py create mode 100644 app/auth/forms.py create mode 100644 app/auth/login.py create mode 100644 app/auth/routes.py rename app/templates/{ => auth}/login.html (79%) diff --git a/app/__init__.py b/app/__init__.py index ea51fdc..7534380 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -28,10 +28,14 @@ celery = Celery(app.name, broker=app.config['CELERY_BROKER_URL'], backend=app.config['CELERY_RESULT_BACKEND']) celery.conf.update(app.config) -# Blueprints +# Errors blueprint from app.errors import bp as errors_bp app.register_blueprint(errors_bp) +# Authentication blueprint +from app.auth import bp as auth_bp +app.register_blueprint(auth_bp, url_prefix='/auth') + from app import views, models manager = Manager(app) diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 0000000..088b033 --- /dev/null +++ b/app/auth/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('auth', __name__) + +from app.auth import routes diff --git a/app/auth/forms.py b/app/auth/forms.py new file mode 100644 index 0000000..b83d55b --- /dev/null +++ b/app/auth/forms.py @@ -0,0 +1,11 @@ +from flask_wtf import FlaskForm +from wtforms.fields import StringField, PasswordField, SubmitField +from wtforms.validators import DataRequired + + +class LoginForm(FlaskForm): + """User login form.""" + + user = StringField('Username', validators=[DataRequired()], render_kw={'autofocus': True}) + pw = PasswordField('Password', validators=[DataRequired()]) + submit_button = SubmitField('Login') diff --git a/app/auth/login.py b/app/auth/login.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/routes.py b/app/auth/routes.py new file mode 100644 index 0000000..9a656c3 --- /dev/null +++ b/app/auth/routes.py @@ -0,0 +1,49 @@ +from app import app, logger, sshhandler +from app.auth import bp +from flask import redirect, request, render_template, session, url_for +from app.auth.forms import LoginForm +from app.scripts_bank.redis_logic import storeUserInRedis, deleteUserInRedis + + + +@bp.route('/login', methods=['GET', 'POST']) +def login(): + """Login page for user to save credentials.""" + form = LoginForm() + if request.method == 'POST': + # If page is accessed via form POST submission + if form.validate_on_submit(): + # Validate form + if 'USER' in session: + # If user already stored in session variable, return home page + return redirect(url_for('viewHosts')) + else: + # Try to save user credentials in Redis. Return index if fails + try: + if storeUserInRedis(request.form['user'], request.form['pw']): + logger.write_log('logged in') + return redirect(url_for('viewHosts')) + except: + logger.write_log('failed to store user data in Redis when logging in') + # Return login page if accessed via GET request + return render_template('auth/login.html', title='Login with SSH credentials', form=form) + +@bp.route('/logout') +def logout(): + """Disconnect all SSH sessions by user.""" + sshhandler.disconnectAllSSHSessions() + try: + currentUser = session['USER'] + deleteUserInRedis() + logger.write_log('deleted user %s data stored in Redis' % (currentUser)) + session.pop('USER', None) + logger.write_log('deleted user %s as stored in session variable' % (currentUser), user=currentUser) + u = session['UUID'] + session.pop('UUID', None) + logger.write_log('deleted UUID %s for user %s as stored in session variable' % (u, currentUser), user=currentUser) + except KeyError: + logger.write_log('Exception thrown on logout.') + return redirect(url_for('index')) + logger.write_log('logged out') + + return redirect(url_for('index')) diff --git a/app/errors/handlers.py b/app/errors/handlers.py index 38effd1..60b3a36 100644 --- a/app/errors/handlers.py +++ b/app/errors/handlers.py @@ -2,13 +2,13 @@ from app.errors import bp -@bp.app.errorhandler(404) +@bp.errorhandler(404) def not_found_error(error): """Return 404 page on 404 error.""" return render_template('errors/404.html', error=error), 404 -@bp.app.errorhandler(500) +@bp.errorhandler(500) def handle_500(error): """Return 500 page on 500 error.""" return render_template('errors/500.html', error=error), 500 diff --git a/app/forms.py b/app/forms.py index 8bfd07e..e5bdbbf 100644 --- a/app/forms.py +++ b/app/forms.py @@ -5,13 +5,6 @@ from wtforms.validators import DataRequired, IPAddress -class LoginForm(FlaskForm): - """User login form.""" - - user = StringField('Username', validators=[DataRequired()]) - pw = PasswordField('Password', validators=[DataRequired()]) - - class LocalCredentialsForm(FlaskForm): """Local credentials form, on a per device basis.""" diff --git a/app/templates/login.html b/app/templates/auth/login.html similarity index 79% rename from app/templates/login.html rename to app/templates/auth/login.html index bc6306a..03b6332 100644 --- a/app/templates/login.html +++ b/app/templates/auth/login.html @@ -1,5 +1,6 @@ {% extends "base.html" %} +{% import 'bootstrap/wtf.html' as wtf %} {% block content %} @@ -7,7 +8,14 @@

Login Form

- +
+

Please enter your SSH credentials

+
+
+ {{ wtf.quick_form(form) }} +
+
+
diff --git a/app/templates/base.html b/app/templates/base.html index 487190e..cecf3bb 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -23,7 +23,7 @@
- {% if session['USER'] or request.path == "/login" %} + {% if session['USER'] or request.path == "/auth/login" %} {% block content %}{% endblock %} {% else %} {% include "/errors/notloggedin.html" %} diff --git a/app/templates/errors/notloggedin.html b/app/templates/errors/notloggedin.html index a37e955..f6d8b97 100644 --- a/app/templates/errors/notloggedin.html +++ b/app/templates/errors/notloggedin.html @@ -1,6 +1,6 @@

- Please set your AD credentials in the login page before proceeding. + Please set your SSH credentials in the login page before proceeding.

- Login + Login

\ No newline at end of file diff --git a/app/templates/inc/navbar-menu.html b/app/templates/inc/navbar-menu.html index 6d1aaf2..760e253 100644 --- a/app/templates/inc/navbar-menu.html +++ b/app/templates/inc/navbar-menu.html @@ -65,10 +65,10 @@ {% else %} - Login + Login {% endif %} diff --git a/app/views.py b/app/views.py index bf9c6e8..23285fa 100644 --- a/app/views.py +++ b/app/views.py @@ -10,13 +10,12 @@ from flask import flash, g, jsonify, redirect, render_template from flask import request, session, url_for from redis import StrictRedis -from .scripts_bank.redis_logic import deleteUserInRedis, resetUserRedisExpireTimer, storeUserInRedis +from .scripts_bank.redis_logic import resetUserRedisExpireTimer, storeUserInRedis from .scripts_bank.lib.functions import checkForVersionUpdate from .scripts_bank.lib.flask_functions import checkUserLoggedInStatus from .forms import AddHostForm, CustomCfgCommandsForm, CustomCommandsForm -from .forms import EditHostForm, EditInterfaceForm, ImportHostsForm, LoginForm -from .forms import LocalCredentialsForm +from .forms import EditHostForm, EditInterfaceForm, ImportHostsForm, LocalCredentialsForm def initialChecks(): @@ -104,54 +103,13 @@ def noHostConnectError(host): def index(): """Return index page for user. - Requires user to be logged in to display index page. - Else attempts to retrieve user credentials from login form. - If successful, stores them in server-side Redis server, with timer set - to automatically clear information after a set time, - or clear when user logs out. - Else, redirect user to login form. + Requires user to be logged in to display home page displaying all devices. + Else, redirect user to index page. """ if 'USER' in session: return redirect(url_for('viewHosts')) else: - try: - if storeUserInRedis(request.form['user'], request.form['pw']): - logger.write_log('logged in') - return redirect(url_for('viewHosts')) - else: - return render_template("index.html", title='Home') - except: - return render_template("index.html", title='Home') - - -@app.route('/login', methods=['GET', 'POST']) -def login(): - """Login page for user to save credentials.""" - form = LoginForm() - if form.validate_on_submit(): - return redirect(url_for('index')) - return render_template('login.html', title='Login with SSH credentials', form=form) - - -@app.route('/logout', methods=['GET', 'POST']) -def logout(): - """Disconnect all SSH sessions by user.""" - sshhandler.disconnectAllSSHSessions() - try: - currentUser = session['USER'] - deleteUserInRedis() - logger.write_log('deleted user %s data stored in Redis' % (currentUser)) - session.pop('USER', None) - logger.write_log('deleted user %s as stored in session variable' % (currentUser), user=currentUser) - u = session['UUID'] - session.pop('UUID', None) - logger.write_log('deleted UUID %s for user %s as stored in session variable' % (u, currentUser), user=currentUser) - except KeyError: - logger.write_log('Exception thrown on logout.') - return redirect(url_for('index')) - logger.write_log('logged out') - - return redirect(url_for('index')) + return render_template("index.html", title='Home') @app.route('/disconnectAllSSH') From d18a5f67211b2a9ffb326a6eed4e4fa4c2fa0228 Mon Sep 17 00:00:00 2001 From: Matt Vitale Date: Wed, 4 Apr 2018 09:22:43 -0400 Subject: [PATCH 04/17] Minor cleanup of unused code sections and comments. --- app/auth/login.py | 0 app/auth/routes.py | 2 +- app/templates/auth/login.html | 22 ---------------------- app/templates/db/viewhosts.html | 12 ++++++------ 4 files changed, 7 insertions(+), 29 deletions(-) delete mode 100644 app/auth/login.py diff --git a/app/auth/login.py b/app/auth/login.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/auth/routes.py b/app/auth/routes.py index 9a656c3..e296b2b 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -5,7 +5,6 @@ from app.scripts_bank.redis_logic import storeUserInRedis, deleteUserInRedis - @bp.route('/login', methods=['GET', 'POST']) def login(): """Login page for user to save credentials.""" @@ -28,6 +27,7 @@ def login(): # Return login page if accessed via GET request return render_template('auth/login.html', title='Login with SSH credentials', form=form) + @bp.route('/logout') def logout(): """Disconnect all SSH sessions by user.""" diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html index 03b6332..98ac3da 100644 --- a/app/templates/auth/login.html +++ b/app/templates/auth/login.html @@ -1,4 +1,3 @@ - {% extends "base.html" %} {% import 'bootstrap/wtf.html' as wtf %} @@ -15,27 +14,6 @@

Please enter your SSH credentials

{{ wtf.quick_form(form) }}
- diff --git a/app/templates/db/viewhosts.html b/app/templates/db/viewhosts.html index 17ead50..c8129ea 100644 --- a/app/templates/db/viewhosts.html +++ b/app/templates/db/viewhosts.html @@ -22,12 +22,12 @@

All network devices in database

- Hostname - IPv4 Address - Type - Status - Options - + Hostname + IPv4 Address + Type + Status + Options + DB ID From 5668a0681345215a3541b1551037e434229784cc Mon Sep 17 00:00:00 2001 From: Matt Vitale Date: Fri, 6 Apr 2018 15:08:54 -0400 Subject: [PATCH 05/17] Updated installation instructions for Ubuntu 16.04 --- docs/_build/doctrees/environment.pickle | Bin 39487 -> 39506 bytes .../doctrees/installation/ubuntu16-04.doctree | Bin 25523 -> 25773 bytes .../_sources/installation/ubuntu16-04.rst.txt | 2 ++ .../_build/html/installation/ubuntu16-04.html | 2 ++ docs/_build/html/searchindex.js | 2 +- docs/installation/ubuntu16-04.rst | 2 ++ 6 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/_build/doctrees/environment.pickle b/docs/_build/doctrees/environment.pickle index 522127a6a6eec34a20e3200c38661a72077d02ec..a5e0689c5b568efa22ddbe98d5dbf0cf74a5f7fe 100644 GIT binary patch literal 39506 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%0D(k%6OGZ}NBy6s&;@ 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%0A8wPXHsO`#UKcc$|9| z+JXK*LEt+s*pmJR9_O-4jgo)27{5mw*W}+TN;LWRq27>xzrY^|!XJ!)(-V9D5Tqgh zVU+Q|HwEF7|0tCz@*94f8_qgIq~FIVm-jgLaWnwkPe9<)E!dLo0jDSS_jyRe^9v|@>P12LJikPxiszMjoEy$H!=&$* zDOdD3_Z2h(->*X86Xn>F?-f1HWltLA{B^PXhPJFZe^Zob&fh}4;rwlZzY~PN8v&;$ z_WM0Z!}1WAXh@@ja@`Y3Xt%bve_zoEcrttS)D6mlN-DoUW2J z44)>6de0%Ka&pIu-c*^~oI+Jwqm`6rB({yp!a|4|jx_Gp8 zsWVF#kK4H4nakoTPuNGl;KcRb6FSy#99=x|(VO7eyq=>z70;j1vo)d6hga0x68-^Z zAuca~*aiF+0(I8vN&Fb>@VMQFpTooxsg%M$S+)x&S?i(4ees@rzL*0lUjBMgywIOW z+RLmaCy_12v-SczJA1G-8l5esi&;Cq2>&RgZ<#f|U?+=(Qg_wPkrGB+$8nN1I-Tn; z72|d;nNQ(E!q)Iov8QbtzUZYsXJ{k)?fy7ldWn}*)`~gBVt-+~j*f*37q<5=E;{*K zdtUW+;2XIeXjte_Ul25!pmMZ@Io7CbBG+3=^xAQ_v8{o2PJPLSW9`0fI~6Y!7iaMw z+HAYH%v#mXEpP@}BUMkb;&)x+x0ugo3+-vU*du48@ZpXwIm_k^|JYC}pFQ{{w(NdTRgx diff --git a/docs/_build/doctrees/installation/ubuntu16-04.doctree b/docs/_build/doctrees/installation/ubuntu16-04.doctree index e02fb65b1166e21f1c5abe5be3aba1bb486cbc4e..cb6b80ae1551c38967d11cdd490c26bf124f911d 100644 GIT binary patch delta 553 zcmcJ{-Ahve7zS{ja}lhIuEd3$*l{k?g_fx8gNpn}lOnU6u11aNnJBe3j-`vJt3X2R z=BW?@$+}rVAiWSmM2UqAHAqPd#r#5qp%JB2BzC%p{(^3P&jatvBSUzSL|<11UdyeF zRj^96=2S$D_K=~6^*pm_j+haRUkQeEJs1x*{XP9@EKzONf6ph3=%uLUXl-l`N5XX4 zbbe6J|A*ZPgb1y{RM*DPD6UKu2(#8ciVL)71x&AJJ;z1yI_yA~N zRY6?la_#{)dCl2wy5?L%r=)ft0J@l)H;dccnjbNZ=a1p89Nn`E=w|EQ1@y4KpaVVf zso*MbpMkA+)77JtE0CifZC+!mI*rm1=?%yoaoGw$&GL_fEChw(xd zy*}Wjc=vw<1|;oZwSqy>5A^~g+)_G%w;V6+#W*jQ#Y~sV2JoJV@*kMwgTr%}=8cMX zm|TDUP0OLF%FF6tE)h+HJsRR{FZ} mjkCUp>G8TLtkYS)j_=I$Kf(rw{Vmv#;?p)@Q+#L6S$+dD6WWph delta 443 zcmYMwPe_w-90qWomk{1dT_PckoX$o}-25}stq5_`6;zx0ZuuXq!;DrjB!j@~0`cOb z5;TGlNF4(48y@r^Lu4p=c!dtB)pQbg&`3(i3HqZ;m(TN@p2PET36jRt$VD_u>e4>= zw z#gULNRWE_JG-^&TNyGIJlQQeN0KBJJ`wLTCxE52^*6m=Lh4ndnVuAbU+)TtD(IVB| zu`KfPB0(nIP6j+9n3HcF1N`dI<@ya1n3uCR?`cR$PeT$|9&KE~3RfGGF!|K`Oqubf z_}#1L+q8c>L7#=DZyBGNXxhR$hnh`nGT`4vMyhWO1KToxdt6gt+&R{;BQ1em4L@YM zMLoC6bZb9$-&MAO&Nf}#Mn15|hR|yqa6J@N`okOeP3!$b9I~+e19Dt!??+A?9hbq1 K4qd{XA^jA#Jh3VO diff --git a/docs/_build/html/_sources/installation/ubuntu16-04.rst.txt b/docs/_build/html/_sources/installation/ubuntu16-04.rst.txt index 8ae25f4..ea4c25f 100644 --- a/docs/_build/html/_sources/installation/ubuntu16-04.rst.txt +++ b/docs/_build/html/_sources/installation/ubuntu16-04.rst.txt @@ -85,6 +85,8 @@ Replace "domain.com" with your actual domain name (lines highlighted) proxy_pass http://localhost:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; } location /netconfig { alias /home/netconfig/netconfig/app/; diff --git a/docs/_build/html/installation/ubuntu16-04.html b/docs/_build/html/installation/ubuntu16-04.html index 66512c8..9ca9ad4 100644 --- a/docs/_build/html/installation/ubuntu16-04.html +++ b/docs/_build/html/installation/ubuntu16-04.html @@ -97,6 +97,8 @@

Contents of /etc/nginx/sites-available/netconfig Date: Sat, 7 Apr 2018 20:40:45 -0400 Subject: [PATCH 06/17] Back out Celery configs --- app/__init__.py | 5 ----- app/tasks.py | 7 ------- config.py | 5 ----- requirements.txt | 1 - 4 files changed, 18 deletions(-) delete mode 100644 app/tasks.py diff --git a/app/__init__.py b/app/__init__.py index 7534380..cc61fe9 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,5 +1,4 @@ import os -from celery import Celery from flask import Flask from flask_sqlalchemy import SQLAlchemy from flask_bootstrap import Bootstrap @@ -24,10 +23,6 @@ sshhandler = SSHHandler() -# Celery -celery = Celery(app.name, broker=app.config['CELERY_BROKER_URL'], backend=app.config['CELERY_RESULT_BACKEND']) -celery.conf.update(app.config) - # Errors blueprint from app.errors import bp as errors_bp app.register_blueprint(errors_bp) diff --git a/app/tasks.py b/app/tasks.py deleted file mode 100644 index 0e3e361..0000000 --- a/app/tasks.py +++ /dev/null @@ -1,7 +0,0 @@ -from app import celery - - -@celery.task -def addTask(activeSession, host): - """Testing task.""" - host.save_config_on_device(activeSession) diff --git a/config.py b/config.py index 3f09649..b34f8fb 100644 --- a/config.py +++ b/config.py @@ -27,11 +27,6 @@ DB_PORT = 6379 DB_NO = 0 -# Celery setup -CELERY_BROKER_URL = 'redis://localhost:6379/0' -CELERY_RESULT_BACKEND = 'redis://localhost:6379/0' -CELERY_TASK_SERIALIZER = 'json' - # Logging settings # LOGFILE is currently not used # SYSLOGFILE is the location where syslog type logs are stored, diff --git a/requirements.txt b/requirements.txt index ffd23a7..03e6cf1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ -celery==4.1.0 ciscoconfparse==1.2.55 Flask==0.12.2 Flask-Bootstrap==3.3.7.1 From 7dd393e783e79cfefa7483cb939a3269d7c8f9bb Mon Sep 17 00:00:00 2001 From: Matt Vitale Date: Tue, 10 Apr 2018 10:52:51 -0400 Subject: [PATCH 07/17] Move interfaceReplaceSlash from views to functions file --- app/scripts_bank/lib/functions.py | 6 ++++++ app/views.py | 27 +-------------------------- 2 files changed, 7 insertions(+), 26 deletions(-) diff --git a/app/scripts_bank/lib/functions.py b/app/scripts_bank/lib/functions.py index e78a52c..9bbec89 100755 --- a/app/scripts_bank/lib/functions.py +++ b/app/scripts_bank/lib/functions.py @@ -107,3 +107,9 @@ def getScriptRunTime(startTime): """Calculate time elapsed since startTime was first measured.""" endTime = getCurrentTime() - startTime return endTime + + +def interfaceReplaceSlash(x): + """Replace all forward slashes in string 'x' with an underscore.""" + x = x.replace('_', '/') + return x diff --git a/app/views.py b/app/views.py index a53ac5c..947dc7c 100644 --- a/app/views.py +++ b/app/views.py @@ -11,7 +11,7 @@ from flask import request, session, url_for from redis import StrictRedis from .scripts_bank.redis_logic import resetUserRedisExpireTimer, storeUserInRedis -from .scripts_bank.lib.functions import checkForVersionUpdate +from .scripts_bank.lib.functions import checkForVersionUpdate, interfaceReplaceSlash from .scripts_bank.lib.flask_functions import checkUserLoggedInStatus from .forms import AddHostForm, CustomCfgCommandsForm, CustomCommandsForm @@ -44,17 +44,6 @@ def ajaxCheckHostActiveSession(x): return 'False' -def interfaceReplaceSlash(x): - """Replace all forward slashes in string 'x' with an underscore.""" - x = x.replace('_', '/') - return x - - -############################### -# Login Creds Timeout - Begin # -############################### - - def init_db(): """Initialize local Redis database.""" db = StrictRedis( @@ -64,16 +53,6 @@ def init_db(): return db -############################# -# Login Creds Timeout - End # -############################# - - -########################## -# Flask Handlers - Begin # -########################## - - @app.before_request def before_request(): """Set auto logout timer for logged in users. @@ -86,10 +65,6 @@ def before_request(): app.permanent_session_lifetime = timedelta(minutes=app.config['SESSIONTIMEOUT']) session.modified = True -######################## -# Flask Handlers - End # -######################## - @app.route('/nohostconnect/') @app.route('/errors/nohostconnect/') From bb38ca114612a700c556d31be19617da0d5f2bbe Mon Sep 17 00:00:00 2001 From: Matt Vitale Date: Tue, 10 Apr 2018 13:16:28 -0400 Subject: [PATCH 08/17] Updated datahandler test to support deleting device from local db --- tests/test_handlers/test_data_handler.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/tests/test_handlers/test_data_handler.py b/tests/test_handlers/test_data_handler.py index f496d1e..6410678 100644 --- a/tests/test_handlers/test_data_handler.py +++ b/tests/test_handlers/test_data_handler.py @@ -16,12 +16,26 @@ def setUp(self): db.drop_all() db.create_all() - def test_addHostToDB(self): + def tearDown(self): + """Cleanup once test completes.""" + del self.datahandler + + def test_addAndDeleteHostInDB(self): """Test adding a host to the database is successful.""" - b, h_id, err = self.datahandler.addHostToDB("test", "192.168.1.5", - "switch", "cisco_ios", - False) - assert b is True + resultAdd, h_id, err = self.datahandler.addHostToDB("test", "192.168.1.5", + "switch", "cisco_ios", + False) + + # Delete host from db if above adding was successful + if resultAdd: + resultDelete = self.datahandler.deleteHostInDB(h_id) + else: + # Force test failure + assert True is False + + # Verify adding host worked + assert resultAdd is True + assert resultDelete is True def test_importHostsToDB(self): """Test importing hosts to database.""" From 671ebd57d1e4fe67e7d604c6490be00dc18af48e Mon Sep 17 00:00:00 2001 From: Matt Vitale Date: Tue, 10 Apr 2018 13:17:47 -0400 Subject: [PATCH 09/17] Initial setup for interface info dropdown in datatables --- app/static/js/viewspecifichost.js | 37 ++++++++++++++++++++++++++ app/templates/db/viewspecifichost.html | 10 ++++--- app/views.py | 30 ++++++++++----------- 3 files changed, 58 insertions(+), 19 deletions(-) diff --git a/app/static/js/viewspecifichost.js b/app/static/js/viewspecifichost.js index 3bb176a..ba0d51f 100644 --- a/app/static/js/viewspecifichost.js +++ b/app/static/js/viewspecifichost.js @@ -1,3 +1,18 @@ +/* Formatting function for row details - modify as you need */ +function format ( d ) { + // `d` is the original data object for the row + return ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + '
POE Status:Status1
Test number:Number2
'; +} + $(document).ready(function() { // Start Uptime section // Get ID of current device from URL, which are the numbers after the last '/' @@ -26,6 +41,11 @@ $(document).ready(function() { orderable: false, className: 'select-checkbox', targets: 0 + }, + { + orderable: false, + className: 'details-control', + targets: 7 }], select: { style: 'multi', @@ -37,6 +57,23 @@ $(document).ready(function() { $('#tblViewSpecificHost tbody').on('click', 'td:first-child', function() { $(this).toggleClass('selected'); }); + + // Add event listener for opening and closing details + $('#tblViewSpecificHost tbody').on('click', 'td.details-control', function () { + var tr = $(this).closest('tr'); + var row = table.row( tr ); + + if ( row.child.isShown() ) { + // This row is already open - close it + row.child.hide(); + tr.removeClass('shown'); + } + else { + // Open this row + row.child( format(row.data()) ).show(); + tr.addClass('shown'); + } + } ); $('#btnEnableInterfaces').click(function(e) { // Get current table Length diff --git a/app/templates/db/viewspecifichost.html b/app/templates/db/viewspecifichost.html index 318d1c5..5ecd0e6 100644 --- a/app/templates/db/viewspecifichost.html +++ b/app/templates/db/viewspecifichost.html @@ -65,7 +65,8 @@

View Host Interfaces

Description Status Protocol - Options + Options + Details @@ -77,11 +78,12 @@

View Host Interfaces

{{ x.description }} {{ x.status }} {{ x.protocol }} - +
-   - + + + {% endfor %} diff --git a/app/views.py b/app/views.py index 947dc7c..3f5e1c9 100644 --- a/app/views.py +++ b/app/views.py @@ -29,21 +29,6 @@ def initialChecks(): title='Home') -@app.route('/ajaxcheckhostactivesshsession/', methods=['GET', 'POST']) -def ajaxCheckHostActiveSession(x): - """Check if existing SSH session for host is currently active. - - Used for AJAX call only, on main viewhosts.html page. - x = host id - """ - host = datahandler.getHostByID(x) - - if host: - if sshhandler.checkHostActiveSSHSession(host): - return 'True' - return 'False' - - def init_db(): """Initialize local Redis database.""" db = StrictRedis( @@ -66,6 +51,21 @@ def before_request(): session.modified = True +@app.route('/ajaxcheckhostactivesshsession/', methods=['GET', 'POST']) +def ajaxCheckHostActiveSession(x): + """Check if existing SSH session for host is currently active. + + Used for AJAX call only, on main viewhosts.html page. + x = host id + """ + host = datahandler.getHostByID(x) + + if host: + if sshhandler.checkHostActiveSSHSession(host): + return 'True' + return 'False' + + @app.route('/nohostconnect/') @app.route('/errors/nohostconnect/') def noHostConnectError(host): From 84339f21b595a66107cfcdb731fd2e9aaa46ae98 Mon Sep 17 00:00:00 2001 From: Matt Vitale Date: Tue, 10 Apr 2018 20:14:59 -0400 Subject: [PATCH 10/17] PoE status results working for IOS devices --- .../device_definitions/base_device.py | 6 +- .../device_definitions/cisco/cisco_asa.py | 6 ++ .../device_definitions/cisco/cisco_ios.py | 33 ++++++++ .../device_definitions/cisco/cisco_nxos.py | 5 ++ app/static/css/styles.css | 8 +- app/static/js/viewspecifichost.js | 79 +++++++++---------- app/templates/db/viewspecifichost.html | 16 ++-- app/templates/viewspecifichostcmds.html | 2 +- app/views.py | 14 ++++ 9 files changed, 115 insertions(+), 54 deletions(-) diff --git a/app/device_classes/device_definitions/base_device.py b/app/device_classes/device_definitions/base_device.py index d6090f4..34c91bd 100644 --- a/app/device_classes/device_definitions/base_device.py +++ b/app/device_classes/device_definitions/base_device.py @@ -53,13 +53,13 @@ def run_ssh_command(self, command, activeSession): activeSession.exit_config_mode() # Try to retrieve command results again try: - result = self.run_ssh_command('show ip interface brief', activeSession) + result = self.run_ssh_command(command, activeSession) # If command still failed, return nothing if "Invalid input detected" in result: - return self.cleanup_ios_output('', '') + return '' except: # If failure to access SSH channel or run command, return nothing - return self.cleanup_ios_output('', '') + return '' # Return command output return result diff --git a/app/device_classes/device_definitions/cisco/cisco_asa.py b/app/device_classes/device_definitions/cisco/cisco_asa.py index 81736b6..761f519 100644 --- a/app/device_classes/device_definitions/cisco/cisco_asa.py +++ b/app/device_classes/device_definitions/cisco/cisco_asa.py @@ -63,6 +63,11 @@ def pull_device_uptime(self, activeSession): uptime = x.split(' ', 2)[2] return uptime + def pull_device_poe_status(self, activeSession): + """Retrieve PoE status for all interfaces.""" + # Return empty result - unsupported on ASA + return {} + def pull_host_interfaces(self, activeSession): """Retrieve list of interfaces on device.""" # result = self.run_ssh_command('show interface ip brief', activeSession) @@ -107,6 +112,7 @@ def clean_interface_description(self, x): def cleanup_asa_output(self, asaOutput): """Clean up returned ASA output from 'show ip interface brief'.""" data = [] + interface = {} # Used to set if we're on the first loop or not notFirstLoop = False diff --git a/app/device_classes/device_definitions/cisco/cisco_ios.py b/app/device_classes/device_definitions/cisco/cisco_ios.py index f746e2d..0616565 100644 --- a/app/device_classes/device_definitions/cisco/cisco_ios.py +++ b/app/device_classes/device_definitions/cisco/cisco_ios.py @@ -143,6 +143,39 @@ def pull_device_uptime(self, activeSession): output = x.split(' ', 3)[-1] return output + def pull_device_poe_status(self, activeSession): # TODO - WRITE TEST FOR + """Retrieve PoE status for all interfaces.""" + status = {} + command = 'show power inline | begin Interface' + result = self.get_cmd_output(command, activeSession) + checkStrings = ['Interface', 'Watts', '---'] + + # If output returned from command execution, parse output + if result: + for x in result: + # If any string from checkStrings in line, skip to next loop iteration + if any(y in x for y in checkStrings): + continue + line = x.split() + + # Convert interface short abbreviation to long name + regExp = re.compile(r'[A-Z][a-z][0-9]\/') + if regExp.search(line[0]): + if line[0][0] == 'G': + line[0] = line[0].replace('Gi', 'GigabitEthernet') + elif line[0][0] == 'F': + line[0] = line[0].replace('Fa', 'FastEthernet') + elif line[0][0] == 'T': + line[0] = line[0].replace('Te', 'TenGigabitEthernet') + elif line[0][0] == 'E': + line[0] = line[0].replace('Eth', 'Ethernet') + + # Line[0] is interface name + # Line[2] is operational status + status[line[0]] = line[2] + # Return dictionary with results + return status + def pull_host_interfaces(self, activeSession): """Retrieve list of interfaces on device.""" resultA = self.run_ssh_command('show ip interface brief', activeSession) diff --git a/app/device_classes/device_definitions/cisco/cisco_nxos.py b/app/device_classes/device_definitions/cisco/cisco_nxos.py index 7b0c61d..c17990b 100644 --- a/app/device_classes/device_definitions/cisco/cisco_nxos.py +++ b/app/device_classes/device_definitions/cisco/cisco_nxos.py @@ -120,6 +120,11 @@ def pull_device_uptime(self, activeSession): output = x.split(' ', 3)[-1] return output + def pull_device_poe_status(self, activeSession): # TODO - WRITE TEST FOR + """Retrieve PoE status for all interfaces.""" + # Return empty result - unsupported on NX-OS + return {} + def pull_host_interfaces(self, activeSession): """Retrieve list of interfaces on device.""" outputResult = '' diff --git a/app/static/css/styles.css b/app/static/css/styles.css index e7a78ac..c5deb42 100644 --- a/app/static/css/styles.css +++ b/app/static/css/styles.css @@ -7,6 +7,12 @@ code { font-family: monospace; color: black; } +.icon-poe-on { + color: green; +} +.icon-poe-off { + color: red; +} .pre-scrollable { /* Overrides bootstrap settings for pre-scrollable */ max-height: 100vh; } @@ -16,7 +22,7 @@ code { text-overflow: ellipsis; white-space: nowrap; padding-top: 6px; - padding-right:10px; + padding-right: 10px; padding-left: 10px; } .inlineSSHLine:focus, .inlineSSHLine:hover { diff --git a/app/static/js/viewspecifichost.js b/app/static/js/viewspecifichost.js index ba0d51f..21bfa58 100644 --- a/app/static/js/viewspecifichost.js +++ b/app/static/js/viewspecifichost.js @@ -1,23 +1,40 @@ -/* Formatting function for row details - modify as you need */ -function format ( d ) { - // `d` is the original data object for the row - return ''+ - ''+ - ''+ - ''+ - ''+ - ''+ - ''+ - ''+ - ''+ - '
POE Status:Status1
Test number:Number2
'; -} - $(document).ready(function() { - // Start Uptime section // Get ID of current device from URL, which are the numbers after the last '/' var loc = location.href.substr(location.href.lastIndexOf('/') + 1); + // Start POE status section + $.ajax({ + url: '/devicepoestatus/' + loc, + success: function(data) { + // Get current table Length + var tableLength = table.page.len(); + // Briefly redraw table with all pages, as the below function can only detect selected rows on visible pages + table.page.len(-1).draw(); + var result = JSON.parse(data); // Parse jsonify'd data from python + for (var key in result) { + var value = result[key]; + if (value == "on") { // If PoE is operationally on, apply corresponding class + var iconClass1 = 'glyphicon-ok'; + var iconClass2 = 'icon-poe-on'; + } + else { // If PoE is operationally off, apply corresponding class + var iconClass1 = 'glyphicon-remove'; + var iconClass2 = 'icon-poe-off'; + } + $("*[id='"+key+"-poe-loading']").addClass('hidden'); // Show status icon + $("*[id='"+key+"-poe-status']").removeClass('hidden'); // Show status icon + $("*[id='"+key+"-poe-icon']").addClass(iconClass1); // Apply class icon to tag + $("*[id='"+key+"-poe-icon']").addClass(iconClass2); // Apply class icon to tag + } + // Redraw table with original item count table length + table.page.len(tableLength).draw(); + // Hide loading spinner for PoE status column + $("#poe-loading").addClass('hidden'); // Hide loading animation icon for PoE column + } + }); + // End POE status section + + // Start Uptime section $.ajax({ url: '/deviceuptime/' + loc, success: function(data) { @@ -41,11 +58,6 @@ $(document).ready(function() { orderable: false, className: 'select-checkbox', targets: 0 - }, - { - orderable: false, - className: 'details-control', - targets: 7 }], select: { style: 'multi', @@ -57,23 +69,6 @@ $(document).ready(function() { $('#tblViewSpecificHost tbody').on('click', 'td:first-child', function() { $(this).toggleClass('selected'); }); - - // Add event listener for opening and closing details - $('#tblViewSpecificHost tbody').on('click', 'td.details-control', function () { - var tr = $(this).closest('tr'); - var row = table.row( tr ); - - if ( row.child.isShown() ) { - // This row is already open - close it - row.child.hide(); - tr.removeClass('shown'); - } - else { - // Open this row - row.child( format(row.data()) ).show(); - tr.addClass('shown'); - } - } ); $('#btnEnableInterfaces').click(function(e) { // Get current table Length @@ -179,14 +174,14 @@ $('#modalConfigInterface').on('shown.bs.modal', function(event) { var modal = $(this) - // Replace all '/' with '-' + // Replace all '/' with '_' interfaceDash = interface.replace(/\//g, '_') - // Replace all '.' with '_' + // Replace all '.' with '=' interfaceDash = interfaceDash.replace(/\./g, '=') - // Replace all '-' with '/' + // Replace all '_' with '/' interfaceTitle = interface.replace(/_/g, '/') - // Replace all '?_ with '.' + // Replace all '=' with '.' interfaceTitle = interfaceTitle.replace(/=/g, '.') modal.find('.modal-title').text('Interface ' + interfaceTitle) diff --git a/app/templates/db/viewspecifichost.html b/app/templates/db/viewspecifichost.html index 5ecd0e6..88f6882 100644 --- a/app/templates/db/viewspecifichost.html +++ b/app/templates/db/viewspecifichost.html @@ -7,7 +7,7 @@
-
+
View Host List Edit Host @@ -16,7 +16,7 @@
-
+

View Host Interfaces

{% if not host %} Host not found in original request. @@ -65,25 +65,27 @@

View Host Interfaces

Description Status Protocol + PoE Options - Details {% for x in result %} - {{ x.name }} + {{ x.name }} {{ x.address }} {{ x.description }} {{ x.status }} {{ x.protocol }} + + -- + - - - +   + {% endfor %} diff --git a/app/templates/viewspecifichostcmds.html b/app/templates/viewspecifichostcmds.html index 9fb44ca..6e85df7 100644 --- a/app/templates/viewspecifichostcmds.html +++ b/app/templates/viewspecifichostcmds.html @@ -1,4 +1,4 @@ -

Commands

+

Commands


diff --git a/app/views.py b/app/views.py index 3f5e1c9..3a4c300 100644 --- a/app/views.py +++ b/app/views.py @@ -1,3 +1,4 @@ +import json import socket from datetime import timedelta @@ -290,6 +291,19 @@ def deviceUptime(x): return jsonify(host.pull_device_uptime(activeSession)) +@app.route('/devicepoestatus/') +def devicePoeStatus(x): + """Get PoE status of all interfaces on device. + + x = host id. + """ + initialChecks() + host = datahandler.getHostByID(x) + activeSession = sshhandler.retrieveSSHSession(host) + logger.write_log('retrieved PoE status for interfaces on host %s' % (host.hostname)) + return json.dumps(host.pull_device_poe_status(activeSession)) + + @app.route('/db/viewhosts/', methods=['GET', 'POST']) def viewSpecificHost(x): """Display specific device page. From c9d811a697d38f6c7f775abe90ce866ec699e135 Mon Sep 17 00:00:00 2001 From: Matt Vitale Date: Wed, 11 Apr 2018 09:57:22 -0400 Subject: [PATCH 11/17] PoE status function written for all devices. Changed from using icons to using text for results --- .../device_definitions/cisco/cisco_ios.py | 4 ++-- app/static/js/viewspecifichost.js | 21 ++++++++++--------- app/templates/db/viewspecifichost.html | 4 ++-- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/app/device_classes/device_definitions/cisco/cisco_ios.py b/app/device_classes/device_definitions/cisco/cisco_ios.py index 0616565..2460db2 100644 --- a/app/device_classes/device_definitions/cisco/cisco_ios.py +++ b/app/device_classes/device_definitions/cisco/cisco_ios.py @@ -153,8 +153,8 @@ def pull_device_poe_status(self, activeSession): # TODO - WRITE TEST FOR # If output returned from command execution, parse output if result: for x in result: - # If any string from checkStrings in line, skip to next loop iteration - if any(y in x for y in checkStrings): + # If any string from checkStrings in line, or line is blank, skip to next loop iteration + if any(y in x for y in checkStrings) or not x: continue line = x.split() diff --git a/app/static/js/viewspecifichost.js b/app/static/js/viewspecifichost.js index 21bfa58..05a4524 100644 --- a/app/static/js/viewspecifichost.js +++ b/app/static/js/viewspecifichost.js @@ -13,18 +13,19 @@ $(document).ready(function() { var result = JSON.parse(data); // Parse jsonify'd data from python for (var key in result) { var value = result[key]; - if (value == "on") { // If PoE is operationally on, apply corresponding class - var iconClass1 = 'glyphicon-ok'; - var iconClass2 = 'icon-poe-on'; - } - else { // If PoE is operationally off, apply corresponding class - var iconClass1 = 'glyphicon-remove'; - var iconClass2 = 'icon-poe-off'; - } + // if (value == "on") { // If PoE is operationally on, apply corresponding class + // var iconClass1 = 'glyphicon-ok'; + // var iconClass2 = 'icon-poe-on'; + // } + // else { // If PoE is operationally off, apply corresponding class + // var iconClass1 = 'glyphicon-remove'; + // var iconClass2 = 'icon-poe-off'; + // } $("*[id='"+key+"-poe-loading']").addClass('hidden'); // Show status icon $("*[id='"+key+"-poe-status']").removeClass('hidden'); // Show status icon - $("*[id='"+key+"-poe-icon']").addClass(iconClass1); // Apply class icon to tag - $("*[id='"+key+"-poe-icon']").addClass(iconClass2); // Apply class icon to tag + $("*[id='"+key+"-poe-status']").text(value); // Show status icon + // $("*[id='"+key+"-poe-icon']").addClass(iconClass1); // Apply class icon to tag + // $("*[id='"+key+"-poe-icon']").addClass(iconClass2); // Apply class icon to tag } // Redraw table with original item count table length table.page.len(tableLength).draw(); diff --git a/app/templates/db/viewspecifichost.html b/app/templates/db/viewspecifichost.html index 88f6882..133d7f1 100644 --- a/app/templates/db/viewspecifichost.html +++ b/app/templates/db/viewspecifichost.html @@ -78,9 +78,9 @@

View Host Interfaces

{{ x.description }} {{ x.status }} {{ x.protocol }} - + -- - + From cb7defc7576a06e1308b61465238a06729023c55 Mon Sep 17 00:00:00 2001 From: Matt Vitale Date: Wed, 11 Apr 2018 12:26:38 -0400 Subject: [PATCH 12/17] Add unittest for new function to pull interface poe status --- tests/test_cisco_asa/test_functions.py | 16 +++++++++++ tests/test_cisco_ios/test_functions.py | 37 +++++++++++++++++++++++++ tests/test_cisco_nxos/test_functions.py | 15 ++++++++++ 3 files changed, 68 insertions(+) diff --git a/tests/test_cisco_asa/test_functions.py b/tests/test_cisco_asa/test_functions.py index 2ffbf52..781519d 100644 --- a/tests/test_cisco_asa/test_functions.py +++ b/tests/test_cisco_asa/test_functions.py @@ -1,5 +1,9 @@ import unittest from app.device_classes.device_definitions.cisco.cisco_asa import CiscoASA +try: + import mock +except ImportError: + from unittest import mock class TestCiscoASA(unittest.TestCase): @@ -149,5 +153,17 @@ def test_clean_interface_description(self): actual_output = self.device.clean_interface_description(input_data) self.assertEqual(actual_output, expected_output) + @mock.patch.object(CiscoASA, 'run_ssh_command') + def test_pull_device_poe_status(self, mocked_method): + """Test MAC address table formatting.""" + mocked_method.return_value = ''' + ^ +ERROR: % Invalid input detected at '^' marker. +''' + + asa_expected_output = {} + + self.assertEqual(self.device.pull_device_poe_status(None), asa_expected_output) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_cisco_ios/test_functions.py b/tests/test_cisco_ios/test_functions.py index 0acfbab..18d9f72 100644 --- a/tests/test_cisco_ios/test_functions.py +++ b/tests/test_cisco_ios/test_functions.py @@ -1,5 +1,9 @@ import unittest from app.device_classes.device_definitions.cisco.cisco_ios import CiscoIOS +try: + import mock +except ImportError: + from unittest import mock class TestCiscoIOS(unittest.TestCase): @@ -37,6 +41,13 @@ def setUp(self): 'address': 'unassigned', 'protocol': 'down', 'description': 'Connection to ABC Switch'}] + def tearDown(self): + """Tear down values in memory once completed.""" + self.device = None + self.interface_input_dataA = None + self.interface_input_dataB = None + self.interface_expected_output = None + def test_cleanup_ios_output(self): """Test IOS interface output cleanup function.""" actual_output = self.device.cleanup_ios_output(self.interface_input_dataA, self.interface_input_dataB) @@ -68,5 +79,31 @@ def test_rename_cdp_interfaces(self): self.assertEqual(self.device.renameCDPInterfaces('Ethernet'), 'Eth ') self.assertEqual(self.device.renameCDPInterfaces('Test123'), 'Test123') + @mock.patch.object(CiscoIOS, 'run_ssh_command') + def test_pull_device_poe_status(self, mocked_method): + """Test MAC address table formatting.""" + self.device.ios_type = 'cisco_ios' + mocked_method.return_value = ''' +Interface Admin Oper Power Device Class Max + (Watts) +--------- ------ ---------- ------- ------------------- ----- ---- +Gi1/0/1 auto off 0.0 n/a n/a 30.0 +Gi1/0/2 auto on 3.9 Polycom SoundPoint 2 30.0 +Gi1/0/3 auto off 0.0 n/a n/a 30.0 +Interface Admin Oper Power Device Class Max + (Watts) +--------- ------ ---------- ------- ------------------- ----- ---- +Gi2/0/1 auto off 0.0 n/a n/a 30.0 +Gi2/0/2 auto on 6.0 IP Phone 6789 1 30.0 +''' + + ios_expected_output = {'GigabitEthernet1/0/1': 'off', + 'GigabitEthernet2/0/2': 'on', + 'GigabitEthernet1/0/3': 'off', + 'GigabitEthernet1/0/2': 'on', + 'GigabitEthernet2/0/1': 'off'} + + self.assertEqual(self.device.pull_device_poe_status(None), ios_expected_output) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_cisco_nxos/test_functions.py b/tests/test_cisco_nxos/test_functions.py index 1195176..a41ac80 100644 --- a/tests/test_cisco_nxos/test_functions.py +++ b/tests/test_cisco_nxos/test_functions.py @@ -1,5 +1,9 @@ import unittest from app.device_classes.device_definitions.cisco.cisco_nxos import CiscoNXOS +try: + import mock +except ImportError: + from unittest import mock class TestCiscoNXOS(unittest.TestCase): @@ -55,6 +59,17 @@ def test_replace_double_spaces_commas(self): actual_output = self.device.replace_double_spaces_commas(input_data) self.assertEqual(actual_output, expected_output) + @mock.patch.object(CiscoNXOS, 'run_ssh_command') + def test_pull_device_poe_status(self, mocked_method): + """Test MAC address table formatting.""" + mocked_method.return_value = ''' + ^ +% Invalid command at '^' marker. +''' + + nxos_expected_output = {} + + self.assertEqual(self.device.pull_device_poe_status(None), nxos_expected_output) if __name__ == '__main__': unittest.main() From 5ba4f70bec72598737ee7c92785e2ceb46b9dc32 Mon Sep 17 00:00:00 2001 From: Matt Vitale Date: Fri, 13 Apr 2018 09:19:03 -0400 Subject: [PATCH 13/17] Add columnDefs to specific device table --- app/static/js/viewspecifichost.js | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/app/static/js/viewspecifichost.js b/app/static/js/viewspecifichost.js index 05a4524..43a7967 100644 --- a/app/static/js/viewspecifichost.js +++ b/app/static/js/viewspecifichost.js @@ -51,14 +51,39 @@ $(document).ready(function() { var events = $('#events'); var table = $('#tblViewSpecificHost').DataTable({ "pageLength": 10, + "order": [], "lengthMenu": [ [10, 25, 50, 100, -1], [10, 25, 50, 100, "All"] ], - columnDefs: [{ + columnDefs: [ + { title: '', orderable: false, className: 'select-checkbox', targets: 0 + }, + { title: 'Interface', + targets: 1 + }, + { title: 'Address', + targets: 2 + }, + { title: 'Description', + targets: 3 + }, + { title: 'Status', + targets: 4 + }, + { title: 'Protocol', + targets: 5 + }, + { title: 'PoE', + targets: 6, + orderable: false + }, + { title: 'Options', + targets: 7, + orderable: false }], select: { style: 'multi', From 630f77cbb49d4753dab4f31b05374e429d26aaa6 Mon Sep 17 00:00:00 2001 From: Matt Vitale Date: Mon, 16 Apr 2018 11:35:08 -0400 Subject: [PATCH 14/17] Bugfix authentication interactions with Redis when using Python 3 --- .gitignore | 1 + app/auth/routes.py | 1 + app/scripts_bank/redis_logic.py | 14 +++++++------- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index f2d24ec..dd15f0f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # Add any directories, files, or patterns you don't want to be tracked by version control\ +*.coverage *.csv *.pyc *.log diff --git a/app/auth/routes.py b/app/auth/routes.py index e296b2b..91149dd 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -41,6 +41,7 @@ def logout(): u = session['UUID'] session.pop('UUID', None) logger.write_log('deleted UUID %s for user %s as stored in session variable' % (u, currentUser), user=currentUser) + u = None except KeyError: logger.write_log('Exception thrown on logout.') return redirect(url_for('index')) diff --git a/app/scripts_bank/redis_logic.py b/app/scripts_bank/redis_logic.py index d220b03..17c9954 100644 --- a/app/scripts_bank/redis_logic.py +++ b/app/scripts_bank/redis_logic.py @@ -10,8 +10,8 @@ def generateSessionUUID(): def deleteUserInRedis(): """Delete logged in user in Redis.""" - saved_id = str(g.db.hget('users', session['USER'])) - g.db.delete(str(saved_id)) + saved_id = g.db.hget('users', session['USER']) + g.db.delete(saved_id) # Delete any locally saved credentials tied to user pattern = '*--' + str(session['USER']) @@ -28,7 +28,7 @@ def resetUserRedisExpireTimer(): x is Redis key to reset timer on. """ try: - saved_id = str(g.db.hget('users', session['USER'])) + saved_id = g.db.hget('users', session['USER']) g.db.expire(saved_id, app.config['REDISKEYTIMEOUT']) except: pass @@ -45,11 +45,11 @@ def storeUserInRedis(user, pw, privpw='', host=''): # If user id doesn't exist, create new one with next available UUID # Else reuse existing key, # to prevent incrementing id each time the same user logs in - if str(g.db.hget('users', user)) == 'None': - # Create new user id, incrementing by 10 - user_id = str(g.db.incrby('next_user_id', 10)) + if g.db.hget('users', user): + user_id = g.db.hget('users', user) else: - user_id = str(g.db.hget('users', user)) + # Create new user id, incrementing by 10 + user_id = g.db.incrby('next_user_id', 10) g.db.hmset(user_id, dict(user=user, pw=pw)) g.db.hset('users', user, user_id) # Set user info timer to auto expire and clear data From bcfdcf0b2e8ba2ada3c308daa5393e1a1e06182e Mon Sep 17 00:00:00 2001 From: Matt Vitale Date: Mon, 16 Apr 2018 13:42:29 -0400 Subject: [PATCH 15/17] Additional bugfix for Redis and Python3 --- app/scripts_bank/redis_logic.py | 18 ++++++++---------- app/ssh_handler.py | 12 ++++++------ app/views.py | 4 +++- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/app/scripts_bank/redis_logic.py b/app/scripts_bank/redis_logic.py index 17c9954..61f3036 100644 --- a/app/scripts_bank/redis_logic.py +++ b/app/scripts_bank/redis_logic.py @@ -17,8 +17,8 @@ def deleteUserInRedis(): pattern = '*--' + str(session['USER']) for key in g.db.hscan_iter('localusers', match=pattern): # key[1] is the value we need to delete - g.db.delete(str(key[1])) - g.db.delete(str(saved_id)) + g.db.delete(key[1]) + g.db.delete(saved_id) def resetUserRedisExpireTimer(): @@ -66,18 +66,16 @@ def storeUserInRedis(user, pw, privpw='', host=''): # Key to save variable is host id, --, and username of logged in # user key = str(host.id) + "--" + str(session['USER']) - if str(g.db.hget('localusers', key)) == 'None': - # Create new host id, incrementing by 10 - saved_id = str(g.db.incrby('next_user_id', 10)) + if g.db.hget('localusers', key): + saved_id = g.db.hget('localusers', key) else: - saved_id = str(g.db.hget('localusers', key)) + # Create new host id, incrementing by 10 + saved_id = g.db.incrby('next_user_id', 10) if privpw: - g.db.hmset(saved_id, dict(user=user, localuser=session[ - 'USER'], pw=pw, privpw=privpw)) + g.db.hmset(saved_id, dict(user=user, localuser=session['USER'], pw=pw, privpw=privpw)) else: - g.db.hmset(saved_id, dict( - user=user, localuser=session['USER'], pw=pw)) + g.db.hmset(saved_id, dict(user=user, localuser=session['USER'], pw=pw)) g.db.hset('localusers', key, saved_id) # Set user info timer to auto expire and clear data g.db.expire(saved_id, app.config['REDISKEYTIMEOUT']) diff --git a/app/ssh_handler.py b/app/ssh_handler.py index 351ca08..9046088 100644 --- a/app/ssh_handler.py +++ b/app/ssh_handler.py @@ -72,18 +72,18 @@ def eraseVarsInMem(): if host.local_creds: # Set key to host id, --, and username of currently logged in user key = str(host.id) + '--' + session['USER'] - saved_id = str(g.db.hget('localusers', key)) - username = str(g.db.hget(str(saved_id), 'user')) - password = str(g.db.hget(str(saved_id), 'pw')) + saved_id = g.db.hget('localusers', key) + username = g.db.hget(saved_id, 'user') + password = g.db.hget(saved_id, 'pw') try: - privpw = str(g.db.hget(str(saved_id), 'privpw')) + privpw = g.db.hget(saved_id, 'privpw') except: # If privpw not set for this device, simply leave it as a blank string pass else: username = session['USER'] - saved_id = str(g.db.hget('users', username)) - password = str(g.db.hget(str(saved_id), 'pw')) + saved_id = g.db.hget('users', username) + password = g.db.hget(saved_id, 'pw') creds = setUserCredentials(username, password, privpw) diff --git a/app/views.py b/app/views.py index 3a4c300..5d690fc 100644 --- a/app/views.py +++ b/app/views.py @@ -35,7 +35,9 @@ def init_db(): db = StrictRedis( host=app.config['DB_HOST'], port=app.config['DB_PORT'], - db=app.config['DB_NO']) + db=app.config['DB_NO'], + charset="utf-8", + decode_responses=True) return db From 2822195c4b464638d43886cf281c638122e4c391 Mon Sep 17 00:00:00 2001 From: Matt Vitale Date: Tue, 3 Jul 2018 21:56:25 -0400 Subject: [PATCH 16/17] Bug fix #81 --- .gitignore | 1 + app/templates/confirm/confirmintedit.html | 2 +- app/views.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index dd15f0f..12de0ed 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ *.log *.db *.vscode +*.idea app/log/ log/* scripts_bank/logs/* diff --git a/app/templates/confirm/confirmintedit.html b/app/templates/confirm/confirmintedit.html index 42ca2ad..3f814b0 100644 --- a/app/templates/confirm/confirmintedit.html +++ b/app/templates/confirm/confirmintedit.html @@ -18,7 +18,7 @@  interface {{ hostinterface }}
{% if datavlan == '' %}{% set datavlan = 0 %}{% else %}  switchport access vlan {{ datavlan }}
{% endif %} {% if voicevlan == '' %}{% set voicevlan = 0 %}{% else %}  switchport voice vlan {{ voicevlan }}
{% endif %} -{% if other == '' %}{% set other = 0 %}{% else %}{% set otherDisplay = other|replace('\n','
  ') %}  {{ otherDisplay | safe }}
+{% if other == '' %}{% set otherURL = 0 %}{% else %}{% set otherDisplay = other|replace('\n','
  ') %}  {{ otherDisplay | safe }}
{% endif %}  {{ host.get_cmd_exit_configuration_mode() }}
diff --git a/app/views.py b/app/views.py index 5d690fc..b39ee60 100644 --- a/app/views.py +++ b/app/views.py @@ -604,7 +604,7 @@ def resultsIntEdit(x, datavlan, voicevlan, other): activeSession = sshhandler.retrieveSSHSession(host) - # Get interface from passed variabel in URL + # Get interface from passed variable in URL hostinterface = request.args.get('int', '') # Decode 'other' string From cc2d8c1a7b7969bea33c557af8242e97e97a25ad Mon Sep 17 00:00:00 2001 From: Matt Vitale Date: Tue, 3 Jul 2018 22:17:09 -0400 Subject: [PATCH 17/17] Version update to 1.3.6 (beta) --- config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.py b/config.py index b34f8fb..b173b9f 100644 --- a/config.py +++ b/config.py @@ -47,4 +47,4 @@ GH_MASTER_BRANCH_URL = 'https://raw.githubusercontent.com/v1tal3/netconfig/master/config.py' # Current version -VERSION = '1.3.5 (beta)' +VERSION = '1.3.6 (beta)'