diff --git a/Readme.md b/Readme.md index 48ae598..1cccc81 100644 --- a/Readme.md +++ b/Readme.md @@ -40,8 +40,39 @@ optional arguments: _Note:_ `--position` - 0 0 is on the top left, -1 -1 is on the lower right. +Password can be added to JSON configuration file(s) as follows: +```bash +pylauncher-protect +``` +Password can be added in the same call using the option + +* `-p (--password) ` + +If not, the user will be prompted to enter it. +Password can be added to all the configuration file referenced within configuration file and any files referenced in the referenced file and so on using the option + +* `-r (--recursive)` + +For all available options and detailed help run + +```bash +~$ pylauncher-protect -h +usage: pylauncher-protect [-h] [--password PASSWORD] [--recursive] configuration +Example: pylauncher-protect -r menus/menu.json -p ***** + +positional arguments: + configuration menu/configuration file + +optional arguments: + -h, --help show this help message and exit + --password PASSWORD, -p PASSWORD + password to be added to json file, if not provided + user is prompted to enter it + --recursive, -r add recursively to all files referenced in json +``` + ## Configuration -Launcher menus are defined via JSON configuration file(s). On top level, the configuration of the menu is divided in the following 3 sections: +Launcher menus are defined via JSON configuration file(s). On top level, the configuration of the menu is divided in the following 4 sections: * __menu-title__ - An optional section to set the menu title. If no title is specified a file name is used instead. @@ -78,8 +109,13 @@ Launcher menus are defined via JSON configuration file(s). On top level, the con } ] ``` +* __password__ - Hash of a password guarding a section of menu and all submenus. Main section to define launcher items. The type of each item is defined with the `type` property. All supported types with available parameters are described in the next section. -An detailed example can be found at [examples/menus/menu_example.json](examples/menus/menu_example.json). +```json +"password": "5f4dcc3b5aa765d61d8327deb882cf99" +``` + +A detailed example can be found at [examples/menus/menu_example.json](examples/menus/menu_example.json). ### Menu Items @@ -91,7 +127,7 @@ For any menu item the two optional parameters __help_link__ and __tip__ can be s #### Styles -The appearance of a menu item can be customized via styles and themes. Therefore each menu item has following 2 optional paramters: +The appearance of a menu item can be customized via styles and themes. Therefore each menu item has following 2 optional parameters: * __style__ - Enables very flexible customization with [QSS](http://doc.qt.io/qt-4.8/stylesheet-syntax.html) syntax * __theme__ - Enables customization using one of the predefined themes. How to define a theme is described in section [Stylesheet](#stylesheet). However we strongly discourage the use of theme on a per menu item basis. @@ -295,6 +331,7 @@ LauncherDetachButton{ } ``` + # Installation ## Anaconda Anaconda comes with all required packages for __pylauncher__. To install the package use @@ -343,3 +380,4 @@ _Note:_ To be able to build the Anaconda package you need to have the `patchelf` conda create -n build_environment python patchelf source activate build_environment ``` + diff --git a/conda-recipe/meta.yaml b/conda-recipe/meta.yaml index 88a6ab5..9081483 100644 --- a/conda-recipe/meta.yaml +++ b/conda-recipe/meta.yaml @@ -24,6 +24,7 @@ build: entry_points: - pylauncher = pylauncher.launcher:main - pylauncher-convert = pylauncher.convert.convert:main + - pylauncher-protect = pylauncher.protect:main about: home: https://github.psi.ch/projects/COS/repos/pylauncher/browse diff --git a/examples/menus2/menu_5.json b/examples/menus2/menu_5.json index 9cd1110..cf1517a 100644 --- a/examples/menus2/menu_5.json +++ b/examples/menus2/menu_5.json @@ -1,24 +1,95 @@ { - "menu-title": - {"text": "F_L1","theme": "green", "style": "color: #FF3388"}, + "password": "5f4dcc3b5aa765d61d8327deb882cf99", + "menu-title": { + "text": "F_L1", + "theme": "green", + "style": "color: #FF3388" + }, "file-choice": [ - {"text": "Load F_L2", "file": "../menus/menu_2.json"} + { + "text": "Load F_L2", + "file": "../menus/menu_2.json" + } ], "menu": [ - {"type": "title", "text": "General"}, - {"type": "cmd", "text": "Who is online", "command": "xeyes", "tip": "List of currently connected users.", "help-link": "http://www.google.com"}, - {"type": "title", "text": "HighLevel Applications", "theme": "green"}, - {"type": "cmd", "text": "ScreenMonitorTool", "command": "ls", "theme": "green", "style": "color: #FF3388"}, - {"type": "cmd", "text": "QE Measurement", "command": "ls -l", "theme": "green"}, - {"type": "separator"}, - {"type": "title", "text": "Utilities", "theme": "green"}, - {"type": "cmd", "text": "Remote network access control by control room", "command": "ls"}, - {"type": "menu", "text": "Strip-tool", "file": "menu_10.json"}, - {"type": "caqtdm", "text": "CA-screen", "macros": "SYS=TEST", "panel": "screen.ui" }, - {"type": "medm", "text": "MEDM-screen", "macros": "SYS=TEST", "panel": "screen.adl" }, - {"type": "cmd", "text": "Test", "command": "pep -mc S10BC02-MBND100:I-SET"}, - {"type": "cmd", "text": "Test 2", "command": "pylauncher -h"}, - {"type": "pep", "text": "Pep type test (.prc)", "panel": "S_TEST_REMOTECTRL_GUDE_SF-CTRL-TEST01.prc"}, - {"type": "pep", "text": "Pep type test (.prc)", "param": "-mc S10BC02-MBND100:I-SET"} + { + "type": "title", + "text": "General" + }, + { + "type": "cmd", + "text": "Who is online", + "command": "xeyes", + "tip": "List of currently connected users.", + "help-link": "http://www.google.com" + }, + { + "type": "title", + "text": "HighLevel Applications", + "theme": "green" + }, + { + "type": "cmd", + "text": "ScreenMonitorTool", + "command": "ls", + "theme": "green", + "style": "color: #FF3388" + }, + { + "type": "cmd", + "text": "QE Measurement", + "command": "ls -l", + "theme": "green" + }, + { + "type": "separator" + }, + { + "type": "title", + "text": "Utilities", + "theme": "green" + }, + { + "type": "cmd", + "text": "Remote network access control by control room", + "command": "ls" + }, + { + "type": "menu", + "text": "Strip-tool", + "file": "menu_10.json" + }, + { + "type": "caqtdm", + "text": "CA-screen", + "macros": "SYS=TEST", + "panel": "screen.ui" + }, + { + "type": "medm", + "text": "MEDM-screen", + "macros": "SYS=TEST", + "panel": "screen.adl" + }, + { + "type": "cmd", + "text": "Test", + "command": "pep -mc S10BC02-MBND100:I-SET" + }, + { + "type": "cmd", + "text": "Test 2", + "command": "pylauncher -h" + }, + { + "type": "pep", + "text": "Pep type test (.prc)", + "panel": "S_TEST_REMOTECTRL_GUDE_SF-CTRL-TEST01.prc" + }, + { + "type": "pep", + "text": "Pep type test (.prc)", + "param": "-mc S10BC02-MBND100:I-SET" + } ] } \ No newline at end of file diff --git a/src/launcher.py b/src/launcher.py index 59b8c0d..ea9fc48 100755 --- a/src/launcher.py +++ b/src/launcher.py @@ -5,19 +5,19 @@ # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. -# ---------python 2/3 compatibility imports--------- - import platform import argparse import copy import enum import shlex import subprocess +import hashlib +import sys from PyQt5 import QtCore from PyQt5.QtCore import Qt from PyQt5.QtGui import QDesktopServices, QIcon, QCursor, QKeySequence -from PyQt5.QtWidgets import QMainWindow, QMenu, QWidgetAction, QLineEdit, QWidget, QHBoxLayout, QToolButton, QVBoxLayout, QCheckBox, QAction, QLabel, QPushButton, QApplication +from PyQt5.QtWidgets import QMainWindow, QMenu, QWidgetAction, QLineEdit, QWidget, QHBoxLayout, QToolButton, QVBoxLayout, QCheckBox, QAction, QLabel, QPushButton, QApplication, QInputDialog, QMessageBox from .launcher_model import * @@ -30,6 +30,35 @@ def stringContains(string, substring, caseSensitive): else: return substring.lower() in string.lower() +def convertPwdToHash(password): + m = hashlib.md5() + m.update(password.encode()) + return m.hexdigest() + +def verifyPassword(object, password): + passwordInput = showPasswordDialog(object) + if passwordInput is not None: + convertedPwd = convertPwdToHash(passwordInput) + if convertedPwd == password: + return True + else: + showWrongPasswordDialog(object) + return False +def showPasswordDialog(object): + + password, ok = QInputDialog.getText(object.window(), + 'Password', + 'Enter password:') + if ok: + return password + return None + +def showWrongPasswordDialog(parent): + messageBox = QMessageBox(parent.window()) + messageBox.setText("Wrong password") + messageBox.setStandardButtons(QMessageBox.Ok) + returnValue = messageBox.exec() + class SearchOptions(enum.Enum): @@ -78,6 +107,7 @@ class LauncherWindow(QMainWindow): theme_base) self.menuModel = self.buildMenuModel(rootFilePath) + self.setWindowTitle(self.menuModel.main_title.text) # QMainWindow has predefined layout. Content should be in the central # widget. Create widget with a QVBoxLayout and set it as central. @@ -117,6 +147,10 @@ class LauncherWindow(QMainWindow): if self.use_sbox: self.searchInput.setMouseTracking(True) + if self.menuModel.password is not None: + if not verifyPassword(self, self.menuModel.password): + sys.exit(-1) + def setNewView(self, rootMenuFile, text=None): """Rebuild launcher from new config file. @@ -128,6 +162,7 @@ class LauncherWindow(QMainWindow): del self.menuModel self.menuModel = self.buildMenuModel(rootMenuFile) + if text: self.setWindowTitle(text) else: @@ -246,6 +281,7 @@ class LauncherMenu(QMenu): candidate = self while type(candidate) != LauncherWindow: candidate = candidate.parent() + print(candidate) return candidate @@ -948,6 +984,7 @@ class LauncherCmdButton(LauncherNamedButton): def __init__(self, itemModel, sectionTitle=None, parent=None): LauncherNamedButton.__init__(self, itemModel, sectionTitle, parent) self.cmd = itemModel.cmd + self.pwd = itemModel.pwd self.clicked.connect(self.executeCmd) toolTip = "" @@ -971,16 +1008,25 @@ class LauncherCmdButton(LauncherNamedButton): cb.setText(self.cmd, mode=cb.Clipboard) - def executeCmd(self): + def executeCmd(self, itemModel): """ Run specified command as a separate process Runs commands from the same environment ($PATH) as the launcher was started. Apart from "bash" it aboarts scripts without shebang on first line (strictly). """ + self.parent().hideAll() # When done hide all popuped menus try: - subprocess.Popen(shlex.split(self.cmd)) + if self.pwd is not None: + """passwordInput = showPasswordDialog(self) + if passwordInput is not None: + convertedPwd = convertPwdToHash(passwordInput) + if convertedPwd != self.pwd: + showWrongPasswordDialog() + return""" + if verifyPassword(self, self.pwd): + subprocess.Popen(shlex.split(self.cmd)) except OSError: warn_msg = "Command \"" + self.cmd + "\" cannot be executed. " + \ "Wrong path or bad/no interpreter." diff --git a/src/launcher_model.py b/src/launcher_model.py index 2340232..bf6a164 100644 --- a/src/launcher_model.py +++ b/src/launcher_model.py @@ -4,17 +4,17 @@ # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. -# ---------python 2/3 compatibility imports--------- import sys from urllib.request import urlopen from urllib.parse import urljoin from urllib.error import URLError -# ------end of python 2/3 compatibility imports----- import os import re import json import logging +import hashlib +import sys def join_launcher_path(base, file): # In case file is absolute path ora full url, base will be ignored @@ -63,6 +63,7 @@ class launcher_menu_model(object): """ def __init__(self, parent, menu_file_path, level, launcher_cfg): + self.password = None self.menu_items = list() self.parent = parent self.level = level @@ -87,6 +88,8 @@ class launcher_menu_model(object): if 0 == self.level: self.flags = menu.get("flags", dict()) + self.password = menu.get("password", None) + main_title_item = menu.get("menu-title", dict()) self.main_title = launcher_main_title_item( main_title_item, @@ -182,7 +185,7 @@ class launcher_menu_model(object): sys.exit() def __repr__(self): - s = "{} (nelm: {})\n".format(self.main_title, len(self.menu_items)) + s = "{} (nelm: {}) {}\n".format(self.main_title, len(self.menu_items), self.password) tabs = "\t" *self.level strings = list(map(repr,self.menu_items)) strings = [tabs + str for str in strings] @@ -258,6 +261,7 @@ class launcher_cmd_item(launcher_menu_model_item): self.cmd = self.cmd.format(**params) + self.pwd = parent.password class launcher_sub_menu_item(launcher_menu_model_item): @@ -302,4 +306,4 @@ class launcher_title_item(launcher_menu_model_item): """Text menu separator.""" def __init__(self, parent, item): - launcher_menu_model_item.__init__(self, parent, item) + launcher_menu_model_item.__init__(self, parent, item) \ No newline at end of file diff --git a/src/protect.py b/src/protect.py new file mode 100755 index 0000000..ba15f9a --- /dev/null +++ b/src/protect.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + +import os +import re +import json +import logging +import hashlib +import argparse +import collections +import sys + +def loadJson(filePath): + try: + with open(filePath) as json_file: + data = json.load(json_file, object_pairs_hook=collections.OrderedDict) + return data + except json.decoder.JSONDecodeError as error: + error_msg = "File \"" + filePath + "\" is empty. " \ + "Json cannot be loaded." + logging.error(error_msg) + except IOError as error: + error_msg = "Json cannot be loaded from file \"" + filePath + "\". " \ + "Wrong path." + logging.error(error_msg) + + sys.exit(-1) + +def processFile(filePath, password, recursive): + data = loadJson(filePath) + dirname = os.path.dirname(filePath) + protected = addPassword(data, hashPassword(password)) + saveFile(protected, filePath) + if recursive: + files = findAllFiles(data) + for file in files: + processFile(os.path.join(dirname, file), password, recursive) + +def findAllFiles(root): + fileList = [] + for key in root: + if key == 'file': + fileList.append(root[key]) + elif isinstance(root[key], list): + for element in root[key]: + if isinstance(element, dict): + fileList += findAllFiles(element) + elif isinstance(root[key], dict): + fileList += findAllFiles(root[key]) + return fileList + +def addPassword(root, password): + + # Append password to json at the beginning + root["password"]=password + root.move_to_end('password', last=False) + return root + +def hashPassword(password): + m = hashlib.md5() + m.update(password.encode()) + return m.hexdigest() + +def saveFile(jsonWithPwd, filename): + with open(filename, 'w') as outfile: + json.dump(jsonWithPwd, outfile, indent=4) + +def main(): + """ Main logic """ + + # Parse input arguments + argsParse = argparse.ArgumentParser(description='Example: pylauncher-protect -r menus/menu.json -p *****') + argsParse.add_argument('configuration', + help="menu/configuration file") + argsParse.add_argument('--password', '-p', + help="password to be added to json file, if not provided user is prompted to enter it") + argsParse.add_argument('--recursive', '-r', + help="add recursively to all files referenced in json", action='store_true') + args = argsParse.parse_args() + + # Add password to json structure + if args.password == None: + # Ask for password + password = input("Enter password: ") + else: + password = args.password + + # Get current dir and create path to json file + cwd = os.getcwd() + jsonFilePath = os.path.join(cwd, args.configuration) + + # Add password to file and, if recursive, to all the files within + processFile(jsonFilePath, password, args.recursive) + +# Start program here +if __name__ == '__main__': + main() \ No newline at end of file