plugin configuration file added
setting "NameDatabase" replaced by "SettingsDir" storing of local settings implemented (CryptoBoxSettings.write())
This commit is contained in:
parent
11c2873934
commit
491d16899f
15 changed files with 267 additions and 24 deletions
|
@ -64,9 +64,15 @@ class CryptoBox:
|
|||
|
||||
# do some initial checks
|
||||
def __runTests(self):
|
||||
self.__runTestUID()
|
||||
self.__runTestRootPriv()
|
||||
|
||||
|
||||
def __runTestUID(self):
|
||||
if os.geteuid() == 0:
|
||||
raise CBEnvironmentError("you may not run the cryptobox as root")
|
||||
|
||||
|
||||
def __runTestRootPriv(self):
|
||||
"""try to run 'super' with 'CryptoBoxRootActions'"""
|
||||
import subprocess
|
||||
|
|
|
@ -104,7 +104,7 @@ class CryptoBoxContainer:
|
|||
if self.type == self.Types["luks"]:
|
||||
self.mount = self.__mountLuks
|
||||
self.umount = self.__umountLuks
|
||||
if self.type == self.Types["plain"]:
|
||||
elif self.type == self.Types["plain"]:
|
||||
self.mount = self.__mountPlain
|
||||
self.umount = self.__umountPlain
|
||||
|
||||
|
|
|
@ -14,6 +14,12 @@ class CryptoBoxPlugin:
|
|||
## does this plugin require admin authentification?
|
||||
requestAuth = False
|
||||
|
||||
## is this plugin enabled by default?
|
||||
enabled = True
|
||||
|
||||
## default rank (0..100) of the plugin in listings (lower value means higher priority)
|
||||
rank = 80
|
||||
|
||||
def __init__(self, cbox, pluginDir):
|
||||
self.cbox = cbox
|
||||
self.hdf = {}
|
||||
|
@ -69,3 +75,46 @@ class CryptoBoxPlugin:
|
|||
for (key, value) in self.hdf.items():
|
||||
hdf.setValue(key, str(value))
|
||||
|
||||
|
||||
def isAuthRequired(self):
|
||||
"""check if this plugin requires authentication
|
||||
first step: check plugin configuration
|
||||
second step: check default value of plugin"""
|
||||
try:
|
||||
if self.cbox.prefs.pluginConf[self.getName()]["requestAuth"] is None:
|
||||
return self.requestAuth
|
||||
if self.cbox.prefs.pluginConf[self.getName()]["requestAuth"]:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
except KeyError:
|
||||
return self.requestAuth
|
||||
|
||||
|
||||
def isEnabled(self):
|
||||
"""check if this plugin is enabled
|
||||
first step: check plugin configuration
|
||||
second step: check default value of plugin"""
|
||||
import types
|
||||
try:
|
||||
if self.cbox.prefs.pluginConf[self.getName()]["enabled"] is None:
|
||||
return self.enabled
|
||||
if self.cbox.prefs.pluginConf[self.getName()]["enabled"]:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
except KeyError:
|
||||
return self.enabled
|
||||
|
||||
|
||||
def getRank(self):
|
||||
"""check the rank of this plugin
|
||||
first step: check plugin configuration
|
||||
second step: check default value of plugin"""
|
||||
try:
|
||||
if self.cbox.prefs.pluginConf[self.getName()]["rank"] is None:
|
||||
return self.rank
|
||||
return int(self.cbox.prefs.pluginConf[self.getName()]["rank"])
|
||||
except KeyError, TypeError:
|
||||
return self.rank
|
||||
|
||||
|
|
|
@ -170,6 +170,9 @@ def run_cryptsetup(args):
|
|||
shell = False,
|
||||
args = cs_args)
|
||||
proc.communicate()
|
||||
## chown the devmapper block device to the cryptobox user
|
||||
if (proc.returncode == 0) and (action == "luksOpen"):
|
||||
os.chown(os.path.join(os.path.sep, "dev", "mapper", destination), os.getuid(), os.getgid())
|
||||
return proc.returncode == 0
|
||||
|
||||
|
||||
|
@ -283,6 +286,11 @@ if __name__ == "__main__":
|
|||
sys.stderr.write("the uid of the caller is zero (root) - this is not allowed\n")
|
||||
sys.exit(100)
|
||||
|
||||
# check if there were arguments
|
||||
if (len(args) == 0):
|
||||
sys.stderr.write("No arguments supplied\n")
|
||||
sys.exit(100)
|
||||
|
||||
# did the user call the "check" action?
|
||||
if (len(args) == 1) and (args[0].lower() == "check"):
|
||||
# exit silently
|
||||
|
|
|
@ -8,6 +8,7 @@ except:
|
|||
raise CryptoBoxExceptions.CBEnvironmentError("couldn't import 'configobj'! Try 'apt-get install python-configobj'.")
|
||||
|
||||
|
||||
|
||||
class CryptoBoxSettings:
|
||||
|
||||
CONF_LOCATIONS = [
|
||||
|
@ -15,6 +16,10 @@ class CryptoBoxSettings:
|
|||
"~/.cryptobox.conf",
|
||||
"/etc/cryptobox/cryptobox.conf"]
|
||||
|
||||
NAMEDB_FILE = "cryptobox_names.db"
|
||||
PLUGINCONF_FILE = "cryptobox_plugins.conf"
|
||||
USERDB_FILE = "cryptobox_users.db"
|
||||
|
||||
|
||||
def __init__(self, config_file=None):
|
||||
self.log = logging.getLogger("CryptoBox")
|
||||
|
@ -25,6 +30,36 @@ class CryptoBoxSettings:
|
|||
self.__configureLogHandler()
|
||||
self.__checkUnknownPreferences()
|
||||
self.nameDB = self.__getNameDatabase()
|
||||
self.pluginConf = self.__getPluginConfig()
|
||||
self.userDB = self.__getUserDB()
|
||||
self.misc_files = self.__getMiscFiles()
|
||||
|
||||
|
||||
def write(self):
|
||||
"""
|
||||
write all local setting files including the content of the "misc" subdirectory
|
||||
"""
|
||||
ok = True
|
||||
try:
|
||||
self.nameDB.write()
|
||||
except IOError:
|
||||
self.log.warn("could not save the name database")
|
||||
ok = False
|
||||
try:
|
||||
self.pluginConf.write()
|
||||
except IOError:
|
||||
self.log.warn("could not save the plugin configuration")
|
||||
ok = False
|
||||
try:
|
||||
self.userDB.write()
|
||||
except IOError:
|
||||
self.log.warn("could not save the user database")
|
||||
ok = False
|
||||
for misc_file in self.misc_files:
|
||||
if not misc_file.save():
|
||||
self.log.warn("could not save a misc setting file (%s)" % misc_file.filename)
|
||||
ok = False
|
||||
return ok
|
||||
|
||||
|
||||
def __getitem__(self, key):
|
||||
|
@ -92,12 +127,12 @@ class CryptoBoxSettings:
|
|||
def __getNameDatabase(self):
|
||||
try:
|
||||
try:
|
||||
nameDB_file = self.prefs["Locations"]["NameDatabase"]
|
||||
nameDB_file = os.path.join(self.prefs["Locations"]["SettingsDir"], self.NAMEDB_FILE)
|
||||
except KeyError:
|
||||
raise CryptoBoxExceptions.CBConfigUndefinedError("Locations", "NameDatabase")
|
||||
raise CryptoBoxExceptions.CBConfigUndefinedError("Locations", "SettingsDir")
|
||||
except SyntaxError:
|
||||
raise CryptoBoxExceptions.CBConfigInvalidValueError("Locations", "NameDatabase", nameDB_file, "failed to interprete the filename of the name database correctly")
|
||||
## create nameDB is necessary
|
||||
raise CryptoBoxExceptions.CBConfigInvalidValueError("Locations", "SettingsDir", nameDB_file, "failed to interprete the filename of the name database correctly (%s)" % nameDB_file)
|
||||
## create nameDB if necessary
|
||||
if os.path.exists(nameDB_file):
|
||||
nameDB = configobj.ConfigObj(nameDB_file)
|
||||
else:
|
||||
|
@ -108,6 +143,63 @@ class CryptoBoxSettings:
|
|||
return nameDB
|
||||
|
||||
|
||||
def __getPluginConfig(self):
|
||||
import StringIO
|
||||
plugin_rules = StringIO.StringIO(self.pluginValidationSpec)
|
||||
try:
|
||||
try:
|
||||
pluginConf_file = os.path.join(self.prefs["Locations"]["SettingsDir"], self.PLUGINCONF_FILE)
|
||||
except KeyError:
|
||||
raise CryptoBoxExceptions.CBConfigUndefinedError("Locations", "SettingsDir")
|
||||
except SyntaxError:
|
||||
raise CryptoBoxExceptions.CBConfigInvalidValueError("Locations", "SettingsDir", pluginConf_file, "failed to interprete the filename of the plugin config file correctly (%s)" % pluginConf_file)
|
||||
## create pluginConf_file if necessary
|
||||
if os.path.exists(pluginConf_file):
|
||||
pluginConf = configobj.ConfigObj(pluginConf_file, configspec=plugin_rules)
|
||||
else:
|
||||
pluginConf = configobj.ConfigObj(pluginConf_file, configspec=plugin_rules, create_empty=True)
|
||||
## validate and convert values according to the spec
|
||||
pluginConf.validate(validate.Validator())
|
||||
## check if pluginConf_file file was created successfully?
|
||||
if not os.path.exists(pluginConf_file):
|
||||
raise CryptoBoxExceptions.CBEnvironmentError("failed to create plugin configuration file (%s)" % pluginConf_file)
|
||||
return pluginConf
|
||||
|
||||
|
||||
def __getUserDB(self):
|
||||
import StringIO, sha
|
||||
userDB_rules = StringIO.StringIO(self.userDatabaseSpec)
|
||||
try:
|
||||
try:
|
||||
userDB_file = os.path.join(self.prefs["Locations"]["SettingsDir"], self.USERDB_FILE)
|
||||
except KeyError:
|
||||
raise CryptoBoxExceptions.CBConfigUndefinedError("Locations", "SettingsDir")
|
||||
except SyntaxError:
|
||||
raise CryptoBoxExceptions.CBConfigInvalidValueError("Locations", "SettingsDir", userDB_file, "failed to interprete the filename of the users database file correctly (%s)" % userDB_file)
|
||||
## create userDB_file if necessary
|
||||
if os.path.exists(userDB_file):
|
||||
userDB = configobj.ConfigObj(userDB_file, configspec=userDB_rules)
|
||||
else:
|
||||
userDB = configobj.ConfigObj(userDB_file, configspec=userDB_rules, create_empty=True)
|
||||
## validate and set default value for "admin" user
|
||||
userDB.validate(validate.Validator())
|
||||
## check if userDB file was created successfully?
|
||||
if not os.path.exists(userDB_file):
|
||||
raise CryptoBoxExceptions.CBEnvironmentError("failed to create user database file (%s)" % userDB_file)
|
||||
## define password hash function - never use "sha" directly - SPOT
|
||||
userDB.getDigest = lambda password: sha.new(password).hexdigest()
|
||||
return userDB
|
||||
|
||||
|
||||
def __getMiscFiles(self):
|
||||
misc_dir = os.path.join(self.prefs["Locations"]["SettingsDir"], "misc")
|
||||
if (not os.path.isdir(misc_dir)) or (not os.access(misc_dir, os.X_OK)):
|
||||
return []
|
||||
return [MiscConfigFile(os.path.join(misc_dir, f), self.log)
|
||||
for f in os.listdir(misc_dir)
|
||||
if os.path.isfile(os.path.join(misc_dir, f))]
|
||||
|
||||
|
||||
def __getConfigFileName(self, config_file):
|
||||
# search for the configuration file
|
||||
import types
|
||||
|
@ -168,7 +260,7 @@ ConfigVolumeLabel = string(min=1,default="cbox_config")
|
|||
|
||||
[Locations]
|
||||
MountParentDir = directoryExists(default="/var/cache/cryptobox/mnt")
|
||||
NameDatabase = fileWriteable(default="/var/cache/cryptobox/volumen_names.db")
|
||||
SettingsDir = directoryExists(default="/var/cache/cryptobox/settings")
|
||||
TemplateDir = directoryExists(default="/usr/share/cryptobox/template")
|
||||
LangDir = directoryExists(default="/usr/share/cryptobox/lang")
|
||||
DocDir = directoryExists(default="/usr/share/doc/cryptobox/html")
|
||||
|
@ -194,6 +286,17 @@ super = fileExecutable(default="/usr/bin/super")
|
|||
CryptoBoxRootActions = string(min=1)
|
||||
"""
|
||||
|
||||
pluginValidationSpec = """
|
||||
[__many__]
|
||||
enabled = boolean(default=None)
|
||||
requestAuth = boolean(default=None)
|
||||
rank = integer(default=None)
|
||||
"""
|
||||
|
||||
userDatabaseSpec = """
|
||||
[admins]
|
||||
admin = string(default=d033e22ae348aeb5660fc2140aec35850c4da997)
|
||||
"""
|
||||
|
||||
|
||||
class CryptoBoxSettingsValidator(validate.Validator):
|
||||
|
@ -235,3 +338,45 @@ class CryptoBoxSettingsValidator(validate.Validator):
|
|||
raise validate.VdtValueError("%s (directory does not exist)" % value)
|
||||
return file_path
|
||||
|
||||
|
||||
|
||||
class MiscConfigFile:
|
||||
|
||||
maxSize = 20480
|
||||
|
||||
def __init__(self, filename, logger):
|
||||
self.filename = filename
|
||||
self.log = logger
|
||||
self.load()
|
||||
|
||||
|
||||
def load(self):
|
||||
fd = open(self.filename, "rb")
|
||||
## limit the maximum size
|
||||
self.content = fd.read(self.maxSize)
|
||||
if fd.tell() == self.maxSize:
|
||||
self.log.warn("file in misc settings directory (%s) is bigger than allowed (%s)" % (self.filename, self.maxSize))
|
||||
fd.close()
|
||||
|
||||
|
||||
def save(self):
|
||||
save_dir = os.path.dirname(self.filename)
|
||||
## create the directory, if necessary
|
||||
if not os.path.isdir(save_dir):
|
||||
try:
|
||||
os.mkdir(save_dir)
|
||||
except IOError:
|
||||
return False
|
||||
## save the content of the file
|
||||
try:
|
||||
fd = open(self.filename, "wb")
|
||||
except IOError:
|
||||
return False
|
||||
try:
|
||||
fd.write(self.content)
|
||||
fd.close()
|
||||
return True
|
||||
except IOError:
|
||||
fd.close()
|
||||
return False
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
#!/usr/bin/env python2.4
|
||||
import os
|
||||
import WebInterfaceSites
|
||||
import sys
|
||||
|
||||
try:
|
||||
import cherrypy
|
||||
|
|
|
@ -60,7 +60,7 @@ class PluginManager:
|
|||
|
||||
|
||||
if __name__ == "__main__":
|
||||
x = PluginManager(None, None, "../plugins")
|
||||
x = PluginManager(None, "../plugins")
|
||||
for a in x.getPlugins():
|
||||
if not a is None:
|
||||
print "Plugin: %s" % a.getName()
|
||||
|
|
|
@ -14,7 +14,12 @@ class WebInterfaceDataset(dict):
|
|||
self.cbox = cbox
|
||||
self.__setConfigValues()
|
||||
self.__setCryptoBoxState()
|
||||
self.__setPluginList(plugins)
|
||||
self.plugins = plugins
|
||||
self.setPluginData()
|
||||
|
||||
|
||||
def setPluginData(self):
|
||||
self.__setPluginList(self.plugins)
|
||||
|
||||
|
||||
def setCurrentDiskState(self, device):
|
||||
|
@ -63,6 +68,7 @@ class WebInterfaceDataset(dict):
|
|||
self["Settings.Stylesheet"] = self.prefs["WebSettings"]["Stylesheet"]
|
||||
self["Settings.Language"] = self.prefs["WebSettings"]["Language"]
|
||||
self["Settings.PluginDir"] = self.prefs["Locations"]["PluginDir"]
|
||||
self["Settings.SettingsDir"] = self.prefs["Locations"]["SettingsDir"]
|
||||
|
||||
|
||||
def __setCryptoBoxState(self):
|
||||
|
@ -85,8 +91,10 @@ class WebInterfaceDataset(dict):
|
|||
lang_data = p.getLanguageData()
|
||||
entryName = "Settings.PluginList." + p.getName()
|
||||
self[entryName] = p.getName()
|
||||
self[entryName + ".Rank"] = lang_data.getValue("Rank", "100")
|
||||
self[entryName + ".Link"] = lang_data.getValue("Link", p.getName())
|
||||
self[entryName + ".Rank"] = p.getRank()
|
||||
self[entryName + ".RequestAuth"] = p.isAuthRequired() and "1" or "0"
|
||||
self[entryName + ".Enabled"] = p.isEnabled() and "1" or "0"
|
||||
for a in p.pluginCapabilities:
|
||||
self[entryName + ".Types." + a] = "1"
|
||||
|
||||
|
|
|
@ -5,9 +5,6 @@ import Plugins
|
|||
from CryptoBoxExceptions import *
|
||||
import cherrypy
|
||||
|
||||
# TODO: for now the admin access is defined statically
|
||||
authDict = {"test": "tester"}
|
||||
|
||||
|
||||
class WebInterfacePlugins:
|
||||
|
||||
|
@ -63,7 +60,7 @@ class WebInterfaceSites:
|
|||
|
||||
## this is a function decorator to check authentication
|
||||
## it has to be defined before any page definition requiring authentification
|
||||
def __requestAuth(self, authDict):
|
||||
def __requestAuth(self=None):
|
||||
def check_credentials(site):
|
||||
def _inner_wrapper(self, *args, **kargs):
|
||||
import base64
|
||||
|
@ -81,8 +78,9 @@ class WebInterfaceSites:
|
|||
except AttributeError:
|
||||
## no cherrypy response header defined
|
||||
pass
|
||||
authDict = self.cbox.prefs.userDB["admins"]
|
||||
if user in authDict.keys():
|
||||
if password == authDict[user]:
|
||||
if self.cbox.prefs.userDB.getDigest(password) == authDict[user]:
|
||||
## ok: return the choosen page
|
||||
self.cbox.log.info("access granted for: %s" % user)
|
||||
return site(self, *args, **kargs)
|
||||
|
@ -203,20 +201,21 @@ class WebInterfaceSites:
|
|||
self.dataset.setCurrentDiskState(plugin.device)
|
||||
if not nextTemplate: nextTemplate = "show_volume"
|
||||
else:
|
||||
self.dataset.setPluginData()
|
||||
if not nextTemplate: nextTemplate = "form_system"
|
||||
## save the currently active plugin name
|
||||
self.dataset["Data.ActivePlugin"] = plugin.getName()
|
||||
return self.__render(nextTemplate, plugin)
|
||||
## apply authentication?
|
||||
if plugin.requestAuth:
|
||||
return lambda **args: self.__requestAuth(authDict)(handler)(self, **args)
|
||||
if plugin.isAuthRequired():
|
||||
return lambda **args: self.__requestAuth()(handler)(self, **args)
|
||||
else:
|
||||
return lambda **args: handler(self, **args)
|
||||
|
||||
|
||||
## test authentication
|
||||
@cherrypy.expose
|
||||
@__requestAuth(None, authDict)
|
||||
@__requestAuth
|
||||
def test(self, weblang=""):
|
||||
self.__resetDataset()
|
||||
self.__setWebLang(weblang)
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
# comma separated list of possible prefixes for accesible devices
|
||||
# beware: .e.g "/dev/hd" grants access to _all_ harddisks
|
||||
AllowedDevices = /dev/loop, /dev/sda
|
||||
AllowedDevices = /dev/loop
|
||||
|
||||
|
||||
# the default name prefix of not unnamed containers
|
||||
|
@ -20,9 +20,9 @@ ConfigVolumeLabel = cbox_config
|
|||
# this directory must be writeable by the cryptobox user (see above)
|
||||
MountParentDir = /var/cache/cryptobox/mnt
|
||||
|
||||
# the name-database file - inside of DataDir
|
||||
#NameDatabase = /var/cache/cryptobox/cryptobox_names.db
|
||||
NameDatabase = cryptobox_names.db
|
||||
# settings directory: contains name database and plugin configuration
|
||||
#SettingsDir = /var/cache/cryptobox/settings
|
||||
SettingsDir = .
|
||||
|
||||
# where are the clearsilver templates?
|
||||
#TemplateDir = /usr/share/cryptobox/templates
|
||||
|
|
|
@ -3,6 +3,7 @@ server.socketPort = 8080
|
|||
#server.environment = "production"
|
||||
server.environment = "development"
|
||||
server.logToScreen = True
|
||||
server.log_tracebacks = True
|
||||
server.threadPool = 1
|
||||
server.reverseDNS = False
|
||||
server.logFile = "cryptoboxwebserver.log"
|
||||
|
|
|
@ -24,7 +24,7 @@ def main():
|
|||
for e in cb.getContainerList():
|
||||
print "\t\t%d\t\t%s - %s - %d" % (cb.getContainerList().index(e), e.getDevice(), e.getName(), e.getType())
|
||||
|
||||
if not cb.getContainerList():
|
||||
if not cb.getContainerList() or len(cb.getContainerList()) < 1:
|
||||
print "no loop devices found for testing"
|
||||
sys.exit(1)
|
||||
|
||||
|
|
22
pythonrewrite/bin/uml-setup.sh
Executable file
22
pythonrewrite/bin/uml-setup.sh
Executable file
|
@ -0,0 +1,22 @@
|
|||
#!/bin/sh
|
||||
|
||||
ROOT_IMG=~/devel-stuff/devel-chroots/cryptobox.img
|
||||
TEST_IMG=test.img
|
||||
TEST_SIZE=256
|
||||
|
||||
# Preparations:
|
||||
# echo "tun" >>/etc/modules
|
||||
# follow the instructions in /usr/share/doc/uml-utilities/README.Debian
|
||||
# add your user to the group 'uml-net'
|
||||
#
|
||||
|
||||
/sbin/ifconfig tap0 &>/dev/null || { echo "tap0 is not configured - read /usr/share/doc/uml-utilities/README.Debian for hints"; exit 1; }
|
||||
|
||||
|
||||
if [ ! -e "$TEST_IMG" ]
|
||||
then echo "Creating testing image file ..."
|
||||
dd if=/dev/zero of="$TEST_IMG" bs=1M count=$TEST_SIZE
|
||||
fi
|
||||
|
||||
linux ubd0="$ROOT_IMG" ubd1="$TEST_IMG" con=xterm hostfs=../ fakehd eth0=daemon
|
||||
|
|
@ -33,6 +33,8 @@ class CryptoBoxPropsConfigTests(unittest.TestCase):
|
|||
"configFileOK" : "cbox-test_ok.conf",
|
||||
"configFileBroken" : "cbox-test_broken.conf",
|
||||
"nameDBFile" : "cryptobox_names.db",
|
||||
"pluginConf" : "cryptobox_plugins.conf",
|
||||
"userDB" : "cryptobox_users.db",
|
||||
"logFile" : "cryptobox.log",
|
||||
"tmpdir" : "cryptobox-mnt" }
|
||||
tmpdirname = ""
|
||||
|
@ -43,7 +45,7 @@ AllowedDevices = /dev/loop
|
|||
DefaultVolumePrefix = "Data "
|
||||
DefaultCipher = aes-cbc-essiv:sha256
|
||||
[Locations]
|
||||
NameDatabase = %s/cryptobox_names.db
|
||||
SettingsDir = %s
|
||||
MountParentDir = %s
|
||||
TemplateDir = ../templates
|
||||
LangDir = ../lang
|
||||
|
@ -105,7 +107,7 @@ CryptoBoxRootActions = CryptoBoxRootActions
|
|||
|
||||
def testBrokenConfigs(self):
|
||||
"""Check various broken configurations"""
|
||||
self.writeConfig("NameDatabase", "#out", filename=self.filenames["configFileBroken"])
|
||||
self.writeConfig("SettingsDir", "#out", filename=self.filenames["configFileBroken"])
|
||||
self.assertRaises(CBConfigError, self.CryptoBox.CryptoBoxProps,self.filenames["configFileBroken"])
|
||||
self.writeConfig("Level", "Level = ho", filename=self.filenames["configFileBroken"])
|
||||
self.assertRaises(CBConfigError, self.CryptoBox.CryptoBoxProps,self.filenames["configFileBroken"])
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
#!/usr/bin/env python2.4
|
||||
|
||||
import unittest
|
||||
import twill
|
||||
import cherrypy
|
||||
|
|
Loading…
Reference in a new issue