Merged async actions.

This commit is contained in:
Nick Daly 2013-11-24 18:46:16 -06:00
commit e23650232b
5 changed files with 70 additions and 57 deletions

View File

@ -1,17 +1,17 @@
#! /usr/bin/env python #! /usr/bin/env python
# -*- mode: python; mode: auto-fill; fill-column: 80 -*- # -*- mode: python; mode: auto-fill; fill-column: 80 -*-
"""Run specified privileged actions as root. """Run specified actions.
Privileged actions run commands with this contract (version 1.0): Actions run commands with this contract (version 1.1):
1. (promise) Privileged actions run as root. 1. (promise) Super-user actions run as root. Normal actions do not.
2. (promise) The actions directory can't be changed at run time. 2. (promise) The actions directory can't be changed at run time.
This guarantees that we can only select from the correct set of actions. This guarantees that we can only select from the correct set of actions.
3. (restriction) Only whitelisted privileged actions can run. 3. (restriction) Only whitelisted actions can run.
A. Scripts in a directory above the actions directory can't be run. A. Scripts in a directory above the actions directory can't be run.
@ -58,6 +58,8 @@ Privileged actions run commands with this contract (version 1.0):
4. (promise) Options are appended to the action. 4. (promise) Options are appended to the action.
Options can be provided as a list or strings.
5. (promise) Output and error strings are returned from the command. 5. (promise) Output and error strings are returned from the command.
6. (limitation) Providing the process with input is not possible. 6. (limitation) Providing the process with input is not possible.
@ -66,26 +68,36 @@ Privileged actions run commands with this contract (version 1.0):
interaction with the spawned process must be carried out through some other interaction with the spawned process must be carried out through some other
method (maybe the process opens a socket, or something). method (maybe the process opens a socket, or something).
7. Option
""" """
import contract import contract
import os import os
import pipes import pipes, shlex, subprocess
import shlex
import subprocess
contract.checkmod(__name__) contract.checkmod(__name__)
def privilegedaction_run(action, options = None): def run(action, options = None, async = False):
return _run(action, options, async, False)
def superuser_run(action, options = None, async = False):
return _run(action, options, async, True)
def _run(action, options = None, async = False, run_as_root = False):
"""Safely run a specific action as root. """Safely run a specific action as root.
pre: pre:
os.sep not in actions os.sep not in action
inv: inv:
True # Actions directory hasn't changed. It's hardcoded :) True # Actions directory hasn't changed. It's hardcoded :)
""" """
DIRECTORY = "actions" DIRECTORY = "actions"
if options == None:
options = []
# contract 3A and 3B: don't call anything outside of the actions directory. # contract 3A and 3B: don't call anything outside of the actions directory.
if os.sep in action: if os.sep in action:
@ -98,18 +110,27 @@ def privilegedaction_run(action, options = None):
if not os.access(cmd, os.F_OK): if not os.access(cmd, os.F_OK):
raise ValueError("Action must exist in action directory.") raise ValueError("Action must exist in action directory.")
if hasattr(options, "__iter__"): cmd = [cmd]
options = " ".join(options)
# contract: 3C, 3D: don't allow users to insert escape characters # contract: 3C, 3D: don't allow users to insert escape characters in options
cmd = ["sudo", "-n", cmd, pipes.quote(options)] if options:
if not hasattr(options, "__iter__"):
options = [options]
cmd += [pipes.quote(option) for option in options]
# contract 1: commands can run via sudo.
if run_as_root:
cmd = ["sudo", "-n"] + cmd
# contract 3C: don't interpret shell escape sequences. # contract 3C: don't interpret shell escape sequences.
# contract 5 (and 6-ish). # contract 5 (and 6-ish).
output, error = \ proc = subprocess.Popen(
subprocess.Popen(cmd, cmd,
stdout = subprocess.PIPE, stdout = subprocess.PIPE,
stderr= subprocess.PIPE, stderr= subprocess.PIPE,
shell=False).communicate() shell=False)
return output, error if not async:
output, error = proc.communicate()
return output, error

View File

@ -3,6 +3,7 @@ from gettext import gettext as _
from modules.auth import require from modules.auth import require
from plugin_mount import PagePlugin from plugin_mount import PagePlugin
from forms import Form from forms import Form
from actions import superuser_run
import cfg import cfg
class Apps(PagePlugin): class Apps(PagePlugin):
@ -16,7 +17,7 @@ class Apps(PagePlugin):
def index(self): def index(self):
main = """ main = """
<p>User Applications are web apps hosted on your %s.</p> <p>User Applications are web apps hosted on your %s.</p>
<p>Eventually this box could be your photo sharing site, your <p>Eventually this box could be your photo sharing site, your
instant messaging site, your social networking site, your news instant messaging site, your social networking site, your news
site. Remember web portals? We can be one of those too. site. Remember web portals? We can be one of those too.
@ -35,4 +36,3 @@ investment in the sentimental value of your family snaps? Keep those
photos local, backed up, easily accessed and free from the whims of photos local, backed up, easily accessed and free from the whims of
some other websites business model.</p> some other websites business model.</p>
""") """)

View File

@ -4,7 +4,7 @@ from modules.auth import require
from plugin_mount import PagePlugin, FormPlugin from plugin_mount import PagePlugin, FormPlugin
import cfg import cfg
from forms import Form from forms import Form
from privilegedactions import privilegedaction_run from actions import superuser_run
from util import Message from util import Message
class xmpp(PagePlugin): class xmpp(PagePlugin):
@ -30,7 +30,7 @@ class configure(FormPlugin, PagePlugin):
sidebar_right = _("<strong>Configure XMPP Server</strong>") sidebar_right = _("<strong>Configure XMPP Server</strong>")
def main(self, xmpp_inband_enable=False, message=None, *args, **kwargs): def main(self, xmpp_inband_enable=False, message=None, *args, **kwargs):
output, error = privilegedaction_run("xmpp-setup", 'status') output, error = superuser_run("xmpp-setup", 'status')
if error: if error:
raise Exception("something is wrong: " + error) raise Exception("something is wrong: " + error)
if "inband_enable" in output.split(): if "inband_enable" in output.split():
@ -40,7 +40,7 @@ class configure(FormPlugin, PagePlugin):
action=cfg.server_dir + "/services/xmpp/configure/index", action=cfg.server_dir + "/services/xmpp/configure/index",
name="configure_xmpp_form", name="configure_xmpp_form",
message=message) message=message)
form.checkbox(_("Allow In-Band Registration"), name="xmpp_inband_enable", form.checkbox(_("Allow In-Band Registration"), name="xmpp_inband_enable",
id="xmpp_inband_enable", checked=xmpp_inband_enable) id="xmpp_inband_enable", checked=xmpp_inband_enable)
# hidden field is needed because checkbox doesn't post if not checked # hidden field is needed because checkbox doesn't post if not checked
form.hidden(name="submitted", value="True") form.hidden(name="submitted", value="True")
@ -92,7 +92,7 @@ class register(FormPlugin, PagePlugin):
if not password: msg.add = _("Must specify a password!") if not password: msg.add = _("Must specify a password!")
if username and password: if username and password:
output, error = privilegedaction_run("xmpp-register", [username, password]) output, error = superuser_run("xmpp-register", [username, password])
if error: if error:
raise Exception("something is wrong: " + error) raise Exception("something is wrong: " + error)

View File

@ -9,7 +9,7 @@ from gettext import gettext as _
from filedict import FileDict from filedict import FileDict
from modules.auth import require from modules.auth import require
from plugin_mount import PagePlugin, FormPlugin from plugin_mount import PagePlugin, FormPlugin
from privilegedactions import privilegedaction_run from actions import superuser_run
import cfg import cfg
from forms import Form from forms import Form
from model import User from model import User
@ -55,7 +55,7 @@ def set_hostname(hostname):
cfg.log.info("Changing hostname to '%s'" % hostname) cfg.log.info("Changing hostname to '%s'" % hostname)
try: try:
privilegedaction_run("hostname-change", hostname) superuser_run("hostname-change", hostname)
# don't persist/cache change unless it was saved successfuly # don't persist/cache change unless it was saved successfuly
sys_store = filedict_con(cfg.store_file, 'sys') sys_store = filedict_con(cfg.store_file, 'sys')
sys_store['hostname'] = hostname sys_store['hostname'] = hostname
@ -141,7 +141,7 @@ class general(FormPlugin, PagePlugin):
time_zone = time_zone.strip() time_zone = time_zone.strip()
if time_zone != sys_store['time_zone']: if time_zone != sys_store['time_zone']:
cfg.log.info("Setting timezone to %s" % time_zone) cfg.log.info("Setting timezone to %s" % time_zone)
privilegedaction_run("timezone-change", [time_zone]) superuser_run("timezone-change", [time_zone])
sys_store['time_zone'] = time_zone sys_store['time_zone'] = time_zone
return message or "Settings updated." return message or "Settings updated."

View File

@ -1,32 +1,16 @@
#! /usr/bin/env python #! /usr/bin/env python
# -*- mode: python; mode: auto-fill; fill-column: 80 -*- # -*- mode: python; mode: auto-fill; fill-column: 80 -*-
from actions import superuser_run, run
import shlex
import subprocess
import sys import sys
from privilegedactions import privilegedaction_run
import unittest import unittest
class TestPrivileged(unittest.TestCase): class TestPrivileged(unittest.TestCase):
"""Verify that privileged actions perform as expected: """Verify that privileged actions perform as expected.
1. Privileged actions run as root. See actions.py for a full description of the expectations.
2. Only whitelisted privileged actions can run.
A. Actions can't be used to run other actions:
$ action="echo 'hi'; rm -rf /"
$ $action
B. Options can't be used to run other actions:
$ options="hi'; rm -rf /;'"
$ "echo " + "'$options'"
C. Scripts in a directory above the actions directory can't be run.
D. Scripts in a directory beneath the actions directory can't be run.
3. The actions directory can't be changed at run time.
""" """
def test_run_as_root(self): def test_run_as_root(self):
@ -35,7 +19,7 @@ class TestPrivileged(unittest.TestCase):
""" """
self.assertEqual( self.assertEqual(
"0", # user 0 is root "0", # user 0 is root
privilegedaction_run("id", "-ur")[0].strip()) superuser_run("id", "-ur")[0].strip())
def test_breakout_actions_dir(self): def test_breakout_actions_dir(self):
"""2. The actions directory can't be changed at run time. """2. The actions directory can't be changed at run time.
@ -55,13 +39,13 @@ class TestPrivileged(unittest.TestCase):
for arg in ("../echo", "/bin/echo"): for arg in ("../echo", "/bin/echo"):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
privilegedaction_run(arg, options) run(arg, options)
def test_breakout_down(self): def test_breakout_down(self):
"""3B. Users can't call actions beneath the actions directory.""" """3B. Users can't call actions beneath the actions directory."""
action="directory/echo" action="directory/echo"
self.assertRaises(ValueError, privilegedaction_run, action) self.assertRaises(ValueError, superuser_run, action)
def test_breakout_actions(self): def test_breakout_actions(self):
"""3C. Actions can't be used to run other actions. """3C. Actions can't be used to run other actions.
@ -78,14 +62,14 @@ class TestPrivileged(unittest.TestCase):
for action in actions: for action in actions:
for option in options: for option in options:
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
output = privilegedaction_run(action, option) output = run(action, option)
# if it somewhow doesn't error, we'd better not evaluate the # if it somewhow doesn't error, we'd better not evaluate the
# data. # data.
self.assertFalse("2" in output[0]) self.assertFalse("2" in output[0])
def test_breakout_option_string(self): def test_breakout_option_string(self):
"""3D. Options can't be used to run other actions. """3D. Option strings can't be used to run other actions.
Verify that shell control characters aren't interpreted. Verify that shell control characters aren't interpreted.
@ -94,12 +78,12 @@ class TestPrivileged(unittest.TestCase):
# counting is safer than actual badness. # counting is safer than actual badness.
options = "good; echo $((1+1))" options = "good; echo $((1+1))"
output, error = privilegedaction_run(action, options) output, error = run(action, options)
self.assertFalse("2" in output) self.assertFalse("2" in output)
def test_breakout_option_list(self): def test_breakout_option_list(self):
"""3D. Options can't be used to run other actions. """3D. Option lists can't be used to run other actions.
Verify that only a string of options is accepted and that we can't just Verify that only a string of options is accepted and that we can't just
tack additional shell control characters onto the list. tack additional shell control characters onto the list.
@ -109,10 +93,18 @@ class TestPrivileged(unittest.TestCase):
# counting is safer than actual badness. # counting is safer than actual badness.
options = ["good", ";", "echo $((1+1))"] options = ["good", ";", "echo $((1+1))"]
output, error = privilegedaction_run(action, options) output, error = run(action, options)
# we'd better not evaluate the data. # we'd better not evaluate the data.
self.assertFalse("2" in output) self.assertFalse("2" in output)
def test_multiple_options(self):
"""4. Multiple options can be provided as a list.
"""
self.assertEqual(
subprocess.check_output(shlex.split("id -ur")).strip(),
run("id", ["-u" ,"-r"])[0].strip())
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()