From fed5bd13f1bdb5c1359c96512fd91e1dd445a9be Mon Sep 17 00:00:00 2001 From: Nick Daly Date: Sun, 11 Nov 2012 13:13:22 -0600 Subject: [PATCH] Externalized ExMachina again, partially reverting change: f1e764f2e5728113f191456236d02fdae6e7680a Partially revert the EM integration change, it's not solid yet. This allows EM to grow on its own. However, I'm not reverting the whole change because I want to make it easy to use EM from an external repository, and most of the Plinth-specific changes are good. To use EM in Plinth again, make sure EM and Plinth are in the same directory before running start.sh. The directory structure should look like: ./exmachina/ ./plinth/ start.sh updates Python's path correctly, so this change should be transparent and Plinth should still run the same. --- exmachina/HOWTO | 59 ----- exmachina/__init__.py | 0 exmachina/exmachina.py | 437 ------------------------------------ exmachina/init_test.sh | 13 -- exmachina/test_exmachina.py | 82 ------- 5 files changed, 591 deletions(-) delete mode 100644 exmachina/HOWTO delete mode 100644 exmachina/__init__.py delete mode 100755 exmachina/exmachina.py delete mode 100755 exmachina/init_test.sh delete mode 100755 exmachina/test_exmachina.py diff --git a/exmachina/HOWTO b/exmachina/HOWTO deleted file mode 100644 index f76b3448f..000000000 --- a/exmachina/HOWTO +++ /dev/null @@ -1,59 +0,0 @@ - -HOWTO: Use exmachina in Plinth to Change Configuration and Restart Services ------------------------------------------------------------------------------ - -exmachina is the name of a small and simple privilege separation layer that -lets Plinth (running as a web application with user/group www-data) edit -FreedomBox system configuration (which requires root privileges). The Augeas -library is used to provide a uniform interface to *most* configuration files in -/etc/, and a wrapper around init.d scripts allows services to be restarted -(which is often the way to have configuration changes go live). - -The exmachina daemon and client library is initialized when Plinth starts, and -is accessible in Plinth code through the "cfg" global variable: - - import cfg - cfg.exmachina.initd.restart("networking") - -Existing on-disk configuration settings can be read back through the "get" -action, and files/settings can be found with the "match" method. For example, -the following would print out a list of all the files in /etc/ with -augeas-editing support, and then print the system's hostname according to -/etc/hostname (a minimal one-line configuration file): - - for fname in c.call.augeas_match("/files/etc/*"): - print fname - cfg.exmachina.augeas.get("/files/etc/hostname/*") - -The mutating Augeas actions ("set", "insert", "remove", etc) operate on an -in-memory cache of the configuration files; to commit all changes (for all -modified files) to disk, use the "save" action. The following would set the -local hostname to fluffy.example.net, and then commit that change to disk. - - cfg.exmachina.augeas.set("/files/etc/hostname/*", "fluffy.example.net") - cfg.exmachina.augeas.save() - -After the above, the /etc/hostname file will be updated, but running network -services will not have picked up on the change. To have changes that have been -written to disk actually take effect, the "initd" module allows access to -restarting system services. In the case of hostnames, in debian there exists a -helper script called "/etc/init.d/hostname.sh", so the following would be used -to update all network daemons to a new hostname: - - cfg.exmachina.initd.restart("hostname.sh") - -Augeas makes some effort to prevent partial writes or configuration file -corruption, and in some cases provides basic type checking. However, it does -not do any logical error detection or correction. For example, two separate -services could be configured to try and listen on the same port, or an invalid -hostname could be specified (eg, "&.^$#"). Application code in Plinth is -responsible for ensuring the logical correctness of configuration changes. - -If there is a serious problem executing an exmachina method call (eg, tried to -restart a non-existent service), a bjsonrpc.exceptions.ServerError exception -will be raised, with the actual exception message (but not the traceback) -passed through. - -Reading: -- Augeas API notes: http://augeas.net/docs/api.html -- exmachina repository: https://github.com/bnewbold/exmachina diff --git a/exmachina/__init__.py b/exmachina/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/exmachina/exmachina.py b/exmachina/exmachina.py deleted file mode 100755 index 21de49322..000000000 --- a/exmachina/exmachina.py +++ /dev/null @@ -1,437 +0,0 @@ -#!/usr/bin/env python - -""" -Author: bnewbold -Date: July 2012 -License: GPLv3 (see http://www.gnu.org/licenses/gpl-3.0.html) - (two helper functions copied from web, as cited below) -Package Requirements: python-augeas, bjsonrpc - -This file implements both ends (privilaged daemon and unprivilaged python -client library) of a crude system configuration message bus, intended for use -(initially) with the Plinth web interface to the FreedomBox operating system. - -The goal is to provide partially-untrusted processes (such as the web interface -running as the www-data user) access to core system configuration files -(through the Augeas library) and daemon control (through the init.d scripts). - -The daemon process (started in the same startup script as Plinth) runs as root -and accepts JSON-RPC method calls through a unix domain socket -(/tmp/exmachina.sock by default). Because file access control may not be -sufficiently flexible for access control, a somewhat-elaborate secret key -mechanism can be used to control access to the RPC mechanism. - -The (optional) shared secret-key mechanism requires clients to first call the -"authenticate" RPC method before any other methods. The secret key is passed to -the server process through stdin at startup (command line arguments could be -snooped by unprivilaged processes), and would presumably be passed on to the -client in the same way. The init_test.sh script demonstrates this mechanism. -""" - -import os -import sys -import grp -import shutil -import argparse -import logging -import socket -import subprocess -import time -import base64 - -import bjsonrpc -import bjsonrpc.handlers -import bjsonrpc.server -import augeas - -log = logging.getLogger(__name__) - - -def execute_service(servicename, action, timeout=10): - """This function mostly ripped from StackOverflow: - http://stackoverflow.com/questions/1556348/python-run-a-process-with-timeout-and-capture-stdout-stderr-and-exit-status - """ - # ensure service name isn't tricky trick - script = "/etc/init.d/" + os.path.split(servicename)[1] - - if not os.path.exists(script): - raise ValueError("so such service: %s" % servicename) - - command_list = [script, action] - log.info("executing: %s" % command_list) - proc = subprocess.Popen(command_list, - bufsize=0, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - poll_seconds = .250 - deadline = time.time() + timeout - while time.time() < deadline and proc.poll() is None: - time.sleep(poll_seconds) - - if proc.poll() is None: - if float(sys.version[:3]) >= 2.6: - proc.terminate() - raise Exception("execution timed out (>%d seconds): %s" % - (timeout, command_list)) - - stdout, stderr = proc.communicate() - # TODO: should raise exception here if proc.returncode != 0? - return stdout, stderr, proc.returncode - -def execute_apt(packagename, action, timeout=120, aptargs=['-q', '-y']): - # ensure package name isn't tricky trick - if action != "update" \ - and (packagename != packagename.strip().split()[0] \ - or packagename.startswith('-')): - raise ValueError("Not a good apt package name: %s" % packagename) - - if action == "update": - command_list = ['apt-get', action] - else: - command_list = ['apt-get', action, packagename] - command_list.extend(aptargs) - log.info("executing: %s" % command_list) - proc = subprocess.Popen(command_list, - bufsize=0, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - poll_seconds = .250 - deadline = time.time() + timeout - while time.time() < deadline and proc.poll() is None: - time.sleep(poll_seconds) - - if proc.poll() is None: - if float(sys.version[:3]) >= 2.6: - proc.terminate() - raise Exception("execution timed out (>%d seconds): %s" % - (timeout, command_list)) - - stdout, stderr = proc.communicate() - return stdout, stderr, proc.returncode - - -class ExMachinaHandler(bjsonrpc.handlers.BaseHandler): - - # authentication state variable. If not None, still need to authenticate; - # if None then authentication not require or was already successful for - # this instantiation of the Handler. This class variable gets optionally - # overridden on a per-process basis - secret_key = None - - def _setup(self): - self.augeas = augeas.Augeas() - - def authenticate(self, secret_key): - if not self.secret_key: - log.warn("Unecessary authentication attempt") - return - if not secret_key.strip() == self.secret_key.strip(): - # fail hard - log.error("Authentication failed!") - sys.exit() - self.secret_key = None - - # ------------- Augeas API Passthrough ----------------- - def augeas_save(self): - if not self.secret_key: - log.info("augeas: saving config") - return self.augeas.save() - - def augeas_set(self, path, value): - if not self.secret_key: - log.info("augeas: set %s=%s" % (path, value)) - return self.augeas.set(path.encode('utf-8'), - value.encode('utf-8')) - - def augeas_setm(self, base, sub, value): - if not self.secret_key: - log.info("augeas: setm %s %s = %s" % (base, sub, value)) - return self.augeas.setm(base.encode('utf-8'), - sub.encode('utf-8'), - value.encode('utf-8')) - - def augeas_get(self, path): - if not self.secret_key: - # reduce verbosity - log.debug("augeas: get %s" % path) - return self.augeas.get(path.encode('utf-8')) - - def augeas_match(self, path): - if not self.secret_key: - # reduce verbosity - log.debug("augeas: match %s" % path) - return self.augeas.match("%s" % path.encode('utf-8')) - - def augeas_insert(self, path, label, before=True): - if not self.secret_key: - log.info("augeas: insert %s=%s" % (path, value)) - return self.augeas.insert(path.encode('utf-8'), - label.encode('utf-8'), - before=before) - - def augeas_move(self, src, dst): - if not self.secret_key: - log.info("augeas: move %s -> %s" % (src, dst)) - return self.augeas.move(src.encode('utf-8'), dst.encode('utf-8')) - - def augeas_remove(self, path): - if not self.secret_key: - log.info("augeas: remove %s" % path) - return self.augeas.remove(path.encode('utf-8')) - - # ------------- Misc. non-Augeas Helpers ----------------- - def set_timezone(self, tzname): - if not self.secret_key: - log.info("reset timezone to %s" % tzname) - tzname = tzname.strip() - tzpath = os.path.join("/usr/share/zoneinfo", tzname) - try: - os.stat(tzpath) - except OSError: - # file not found - raise ValueError("timezone not valid: %s" % tzname) - shutil.copy( - os.path.join("/usr/share/zoneinfo", tzname), - "/etc/localtime") - with open("/etc/timezone", "w") as tzfile: - tzfile.write(tzname + "\n") - return "timezone changed to %s" % tzname - - # ------------- init.d Service Control ----------------- - def initd_status(self, servicename): - if not self.secret_key: - return execute_service(servicename, "status") - - def initd_start(self, servicename): - if not self.secret_key: - return execute_service(servicename, "start") - - def initd_stop(self, servicename): - if not self.secret_key: - return execute_service(servicename, "stop") - - def initd_restart(self, servicename): - if not self.secret_key: - return execute_service(servicename, "restart") - - # ------------- apt-get Package Control ----------------- - def apt_install(self, packagename): - if not self.secret_key: - return execute_apt(packagename, "install") - - def apt_update(self): - if not self.secret_key: - return execute_apt("", "update") - - def apt_remove(self, packagename): - if not self.secret_key: - return execute_apt(packagename, "remove") - - -class EmptyClass(): - # Used by ExMachinaClient below - pass - - -class ExMachinaClient(): - """Simple client wrapper library to expose augeas and init.d methods. - - In brief, use augeas.get/set/insert to modify system configuration files - under the /files/etc/* namespace. augeas.match with a wildcard can be used - to find variables to edit. - - After making any changes, use augeas.save to commit to disk, then - initd.restart to restart the appropriate system daemons. In many cases, - this would be the 'networking' meta-daemon. - - See test_exmachina.py for some simple examples; see the augeas docs for - more in depth guidance. - """ - - def __init__(self, - socket_path="/tmp/exmachina.sock", - secret_key=None): - self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - self.sock.connect(socket_path) - self.conn = bjsonrpc.connection.Connection(self.sock) - - if secret_key: - self.conn.call.authenticate(secret_key) - - self.augeas = EmptyClass() - self.initd = EmptyClass() - self.apt = EmptyClass() - self.misc = EmptyClass() - - self.augeas.save = self.conn.call.augeas_save - self.augeas.set = self.conn.call.augeas_set - self.augeas.setm = self.conn.call.augeas_setm - self.augeas.get = self.conn.call.augeas_get - self.augeas.match = self.conn.call.augeas_match - self.augeas.insert = self.conn.call.augeas_insert - self.augeas.move = self.conn.call.augeas_move - self.augeas.remove = self.conn.call.augeas_remove - self.initd.status = self.conn.call.initd_status - self.initd.start = self.conn.call.initd_start - self.initd.stop = self.conn.call.initd_stop - self.initd.restart = self.conn.call.initd_restart - self.apt.install = self.conn.call.apt_install - self.apt.update = self.conn.call.apt_update - self.apt.remove = self.conn.call.apt_remove - self.misc.set_timezone = self.conn.call.set_timezone - - def close(self): - self.sock.close() - - -def run_server(socket_path, secret_key=None, socket_group=None): - - if not 0 == os.geteuid(): - log.warn("Expected to be running as root!") - - if os.path.exists(socket_path): - os.unlink(socket_path) - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.bind(socket_path) - sock.listen(1) - - if socket_group is not None: - socket_uid = os.stat(socket_path).st_uid - socket_gid = grp.getgrnam(socket_group).gr_gid - os.chmod(socket_path, 0660) - os.chown(socket_path, socket_uid, socket_gid) - else: - os.chmod(socket_path, 0666) - if secret_key: - ExMachinaHandler.secret_key = secret_key - - serv = bjsonrpc.server.Server(sock, handler_factory=ExMachinaHandler) - serv.serve() - -def daemonize(stdin='/dev/null', stdout='/dev/null', stderr='/dev/null'): - """ - From: http://www.noah.org/wiki/Daemonize_Python - - This forks the current process into a daemon. The stdin, stdout, and - stderr arguments are file names that will be opened and be used to replace - the standard file descriptors in sys.stdin, sys.stdout, and sys.stderr. - These arguments are optional and default to /dev/null. Note that stderr is - opened unbuffered, so if it shares a file with stdout then interleaved - output may not appear in the order that you expect. """ - - # Do first fork. - try: - pid = os.fork() - if pid > 0: - sys.exit(0) # Exit first parent. - except OSError, e: - sys.stderr.write("fork #1 failed: (%d) %s\n" % (e.errno, e.strerror)) - sys.exit(1) - - # Decouple from parent environment. - os.chdir("/") - os.umask(0) - os.setsid() - - # Do second fork. - try: - pid = os.fork() - if pid > 0: - sys.exit(0) # Exit second parent. - except OSError, e: - sys.stderr.write("fork #2 failed: (%d) %s\n" % (e.errno, e.strerror)) - sys.exit(1) - - # Now I am a daemon! - - # Redirect standard file descriptors. - si = open(stdin, 'r') - so = open(stdout, 'a+') - se = open(stderr, 'a+', 0) - os.dup2(si.fileno(), sys.stdin.fileno()) - os.dup2(so.fileno(), sys.stdout.fileno()) - os.dup2(se.fileno(), sys.stderr.fileno()) - return pid - -# ============================================================================= -# Command line handling -def main(): - - global log - parser = argparse.ArgumentParser(usage= - "usage: %prog [options]\n" - "%prog --help for more info." - ) - parser.add_argument("-v", "--verbose", - default=False, - help="Show more debugging statements", - action="store_true") - parser.add_argument("-q", "--quiet", - default=False, - help="Show fewer informational statements", - action="store_true") - parser.add_argument("-k", "--key", - default=False, - help="Wait for Secret Access Key on stdin before starting", - action="store_true") - parser.add_argument("--random-key", - default=False, - help="Just dump a random base64 key and exit", - action="store_true") - parser.add_argument("-s", "--socket-path", - default="/tmp/exmachina.sock", - help="UNIX Domain socket file path to listen on", - metavar="FILE") - parser.add_argument("--pidfile", - default=None, - help="Daemonize and write pid to this file", - metavar="FILE") - parser.add_argument("-g", "--group", - default=None, - help="chgrp socket file to this group and set 0660 permissions") - - args = parser.parse_args() - - #if len(args) != 0: - #parser.error("Incorrect number of arguments") - - if args.random_key: - sys.stdout.write(base64.urlsafe_b64encode(os.urandom(128))) - sys.exit(0) - - log = logging.getLogger() - hdlr = logging.StreamHandler() - formatter = logging.Formatter('%(asctime)s %(levelname)s %(message)s') - hdlr.setFormatter(formatter) - log.addHandler(hdlr) - - if args.verbose: - log.setLevel(logging.DEBUG) - elif args.quiet: - log.setLevel(logging.ERROR) - else: - log.setLevel(logging.INFO) - - secret_key = None - if args.key: - log.debug("Waiting for secret key on stdin...") - secret_key = sys.stdin.readline().strip() - log.debug("Got it!") - - if args.pidfile: - with open(args.pidfile, 'w') as pfile: - # ensure file is available/writable - pass - os.unlink(args.pidfile) - daemonize() - pid = os.getpid() - with open(args.pidfile, 'w') as pfile: - pfile.write("%s" % pid) - log.info("Daemonized, pid is %s" % pid) - - run_server(secret_key=secret_key, - socket_path=args.socket_path, - socket_group=args.group) - -if __name__ == '__main__': - main() diff --git a/exmachina/init_test.sh b/exmachina/init_test.sh deleted file mode 100755 index 941285d36..000000000 --- a/exmachina/init_test.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash - -# Test init.d-style initialization; run this script as root (or sudo it) - -export key=`./exmachina.py --random-key` - -echo $key | ./exmachina.py -vk --pidfile /tmp/exmachina_test.pid -g www-data -sleep 1 -echo $key | sudo -u www-data -g www-data ./test_exmachina.py -k - -kill `cat /tmp/exmachina_test.pid` && rm /tmp/exmachina_test.pid -sleep 1 -jobs diff --git a/exmachina/test_exmachina.py b/exmachina/test_exmachina.py deleted file mode 100755 index 6c1db9418..000000000 --- a/exmachina/test_exmachina.py +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env python - -""" -This file tests the "client side" of the exmachina layer. - -To use with secret keys, do the following in seperate terminals: - - $ echo "" | sudo ./exmachina.py -vk - $ echo "" | ./test_exmachina.py -k - -To use without, do the following in seperate terminals: - - $ sudo ./exmachina.py -v - $ ./test_exmachina.py - -Use the init_test.sh script to test shared key passing and privilage seperation -at the same time: - - $ sudo ./init_test.sh -""" - -import sys -import socket - -import bjsonrpc -import bjsonrpc.connection -from bjsonrpc.exceptions import ServerError - -from exmachina import ExMachinaClient - -# ============================================================================= -# Command line handling -def main(): - - socket_path = "/tmp/exmachina.sock" - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.connect(socket_path) - - secret_key = None - if sys.argv[-1] == "-k": - print "waiting for key on stdin..." - secret_key = sys.stdin.readline() - print "sent!" - - print "========= Testing JSON-RPC connection" - c = bjsonrpc.connection.Connection(sock) - if secret_key: - c.call.authenticate(secret_key) - print "/*: %s" % c.call.augeas_match("/*") - print "/augeas/*: %s" % c.call.augeas_match("/augeas/*") - print "/etc/* files:" - for name in c.call.augeas_match("/files/etc/*"): - print "\t%s" % name - print c.call.initd_status("bluetooth") - print "hostname: %s" % c.call.augeas_get("/files/etc/hostname/*") - print "localhost: %s" % c.call.augeas_get("/files/etc/hosts/1/canonical") - sock.close() - - print "========= Testing user client library" - client = ExMachinaClient(secret_key=secret_key) - print client.augeas.match("/files/etc/*") - #print client.initd.restart("bluetooth") - try: - print client.initd.status("greentooth") - print "ERROR: should have failed above!" - except ServerError: - print "(got expected error, good!)" - print "(expect Error on the above line)" - print client.initd.status("bluetooth") - print client.apt.install("pkg_which_does_not_exist") - print client.apt.remove("pkg_which_does_not_exist") - #print client.apt.update() # can be slow... - #print client.misc.set_timezone("UTC") # don't clobber system... - try: - print client.misc.set_timezone("whoopie") # should be an error - print "ERROR: should have failed above!" - except ServerError: - print "(got expected error, good!)" - client.close() - -if __name__ == '__main__': - main()