1# Copyright (c) 2013 The Chromium OS Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5import errno
6import os
7import shutil
8import time
9
10from autotest_lib.client.bin import utils
11
12class NetworkChroot(object):
13    """Implements a chroot environment that runs in a separate network
14    namespace from the caller.  This is useful for network tests that
15    involve creating a server on the other end of a virtual ethernet
16    pair.  This object is initialized with an interface name to pass
17    to the chroot, as well as the IP address to assign to this
18    interface, since in passing the interface into the chroot, any
19    pre-configured address is removed.
20
21    The startup of the chroot is an orchestrated process where a
22    small startup script is run to perform the following tasks:
23      - Write out pid file which will be a handle to the
24        network namespace that that |interface| should be passed to.
25      - Wait for the network namespace to be passed in, by performing
26        a "sleep" and writing the pid of this process as well.  Our
27        parent will kill this process to resume the startup process.
28      - We can now configure the network interface with an address.
29      - At this point, we can now start any user-requested server
30        processes.
31    """
32    BIND_ROOT_DIRECTORIES = ('bin', 'dev', 'dev/pts', 'lib', 'lib32', 'lib64',
33                             'proc', 'sbin', 'sys', 'usr', 'usr/local')
34    # Subset of BIND_ROOT_DIRECTORIES that should be mounted writable.
35    BIND_ROOT_WRITABLE_DIRECTORIES = frozenset(('dev/pts',))
36    # Directories we'll bind mount when we want to bridge DBus namespaces.
37    # Includes directories containing the system bus socket and machine ID.
38    DBUS_BRIDGE_DIRECTORIES = ('var/run/dbus/', 'var/lib/dbus/')
39
40    ROOT_DIRECTORIES = ('etc',  'tmp', 'var', 'var/log', 'var/run')
41    STARTUP = 'etc/chroot_startup.sh'
42    STARTUP_DELAY_SECONDS = 5
43    STARTUP_PID_FILE = 'var/run/vpn_startup.pid'
44    STARTUP_SLEEPER_PID_FILE = 'var/run/vpn_sleeper.pid'
45    COPIED_CONFIG_FILES = [
46        'etc/ld.so.cache'
47    ]
48    CONFIG_FILE_TEMPLATES = {
49        STARTUP:
50            '#!/bin/sh\n'
51            'exec > /var/log/startup.log 2>&1\n'
52            'set -x\n'
53            'echo $$ > /%(startup-pidfile)s\n'
54            'sleep %(startup-delay-seconds)d &\n'
55            'echo $! > /%(sleeper-pidfile)s &\n'
56            'wait\n'
57            'ip addr add %(local-ip-and-prefix)s dev %(local-interface-name)s\n'
58            'ip link set %(local-interface-name)s up\n'
59    }
60    CONFIG_FILE_VALUES = {
61        'sleeper-pidfile': STARTUP_SLEEPER_PID_FILE,
62        'startup-delay-seconds': STARTUP_DELAY_SECONDS,
63        'startup-pidfile': STARTUP_PID_FILE
64    }
65
66    def __init__(self, interface, address, prefix):
67        self._interface = interface
68
69        # Copy these values from the class-static since specific instances
70        # of this class are allowed to modify their contents.
71        self._bind_root_directories = list(self.BIND_ROOT_DIRECTORIES)
72        self._root_directories = list(self.ROOT_DIRECTORIES)
73        self._copied_config_files = list(self.COPIED_CONFIG_FILES)
74        self._config_file_templates = self.CONFIG_FILE_TEMPLATES.copy()
75        self._config_file_values = self.CONFIG_FILE_VALUES.copy()
76
77        self._config_file_values.update({
78            'local-interface-name': interface,
79            'local-ip': address,
80            'local-ip-and-prefix': '%s/%d' % (address, prefix)
81        })
82
83
84    def startup(self):
85        """Create the chroot and start user processes."""
86        self.make_chroot()
87        self.write_configs()
88        self.run(['/bin/bash', os.path.join('/', self.STARTUP), '&'])
89        self.move_interface_to_chroot_namespace()
90        self.kill_pid_file(self.STARTUP_SLEEPER_PID_FILE)
91
92
93    def shutdown(self):
94        """Remove the chroot filesystem in which the VPN server was running"""
95        # TODO(pstew): Some processes take a while to exit, which will cause
96        # the cleanup below to fail to complete successfully...
97        time.sleep(10)
98        utils.system_output('rm -rf --one-file-system %s' % self._temp_dir,
99                            ignore_status=True)
100
101
102    def add_config_templates(self, template_dict):
103        """Add a filename-content dict to the set of templates for the chroot
104
105        @param template_dict dict containing filename-content pairs for
106            templates to be applied to the chroot.  The keys to this dict
107            should not contain a leading '/'.
108
109        """
110        self._config_file_templates.update(template_dict)
111
112
113    def add_config_values(self, value_dict):
114        """Add a name-value dict to the set of values for the config template
115
116        @param value_dict dict containing key-value pairs of values that will
117            be applied to the config file templates.
118
119        """
120        self._config_file_values.update(value_dict)
121
122
123    def add_copied_config_files(self, files):
124        """Add |files| to the set to be copied to the chroot.
125
126        @param files iterable object containing a list of files to
127            be copied into the chroot.  These elements should not contain a
128            leading '/'.
129
130        """
131        self._copied_config_files += files
132
133
134    def add_root_directories(self, directories):
135        """Add |directories| to the set created within the chroot.
136
137        @param directories list/tuple containing a list of directories to
138            be created in the chroot.  These elements should not contain a
139            leading '/'.
140
141        """
142        self._root_directories += directories
143
144
145    def add_startup_command(self, command):
146        """Add a command to the script run when the chroot starts up.
147
148        @param command string containing the command line to run.
149
150        """
151        self._config_file_templates[self.STARTUP] += '%s\n' % command
152
153
154    def get_log_contents(self):
155        """Return the logfiles from the chroot."""
156        return utils.system_output("head -10000 %s" %
157                                   self.chroot_path("var/log/*"))
158
159
160    def bridge_dbus_namespaces(self):
161        """Make the system DBus daemon visible inside the chroot."""
162        # Need the system socket and the machine-id.
163        self._bind_root_directories += self.DBUS_BRIDGE_DIRECTORIES
164
165
166    def chroot_path(self, path):
167        """Returns the the path within the chroot for |path|.
168
169        @param path string filename within the choot.  This should not
170            contain a leading '/'.
171
172        """
173        return os.path.join(self._temp_dir, path.lstrip('/'))
174
175
176    def get_pid_file(self, pid_file, missing_ok=False):
177        """Returns the integer contents of |pid_file| in the chroot.
178
179        @param pid_file string containing the filename within the choot
180            to read and convert to an integer.  This should not contain a
181            leading '/'.
182        @param missing_ok bool indicating whether exceptions due to failure
183            to open the pid file should be caught.  If true a missing pid
184            file will cause this method to return 0.  If false, a missing
185            pid file will cause an exception.
186
187        """
188        chroot_pid_file = self.chroot_path(pid_file)
189        try:
190            with open(chroot_pid_file) as f:
191                return int(f.read())
192        except IOError, e:
193            if not missing_ok or e.errno != errno.ENOENT:
194                raise e
195
196            return 0
197
198
199    def kill_pid_file(self, pid_file, missing_ok=False):
200        """Kills the process belonging to |pid_file| in the chroot.
201
202        @param pid_file string filename within the chroot to gain the process ID
203            which this method will kill.
204        @param missing_ok bool indicating whether a missing pid file is okay,
205            and should be ignored.
206
207        """
208        pid = self.get_pid_file(pid_file, missing_ok=missing_ok)
209        if missing_ok and pid == 0:
210            return
211        utils.system('kill %d' % pid, ignore_status=True)
212
213
214    def make_chroot(self):
215        """Make a chroot filesystem."""
216        self._temp_dir = utils.system_output('mktemp -d /tmp/chroot.XXXXXXXXX')
217        utils.system('chmod go+rX %s' % self._temp_dir)
218        for rootdir in self._root_directories:
219            os.mkdir(self.chroot_path(rootdir))
220
221        self._jail_args = []
222        for rootdir in self._bind_root_directories:
223            src_path = os.path.join('/', rootdir)
224            dst_path = self.chroot_path(rootdir)
225            if not os.path.exists(src_path):
226                continue
227            elif os.path.islink(src_path):
228                link_path = os.readlink(src_path)
229                os.symlink(link_path, dst_path)
230            else:
231                os.makedirs(dst_path)  # Recursively create directories.
232                mount_arg = '%s,%s' % (src_path, src_path)
233                if rootdir in self.BIND_ROOT_WRITABLE_DIRECTORIES:
234                    mount_arg += ',1'
235                self._jail_args += [ '-b', mount_arg ]
236
237        for config_file in self._copied_config_files:
238            src_path = os.path.join('/', config_file)
239            dst_path = self.chroot_path(config_file)
240            if os.path.exists(src_path):
241                shutil.copyfile(src_path, dst_path)
242
243
244    def move_interface_to_chroot_namespace(self):
245        """Move network interface to the network namespace of the server."""
246        utils.system('ip link set %s netns %d' %
247                     (self._interface,
248                      self.get_pid_file(self.STARTUP_PID_FILE)))
249
250
251    def run(self, args, ignore_status=False):
252        """Run a command in a chroot, within a separate network namespace.
253
254        @param args list containing the command line arguments to run.
255        @param ignore_status bool set to true if a failure should be ignored.
256
257        """
258        utils.system('minijail0 -e -C %s %s' %
259                     (self._temp_dir, ' '.join(self._jail_args + args)),
260                     ignore_status=ignore_status)
261
262
263    def write_configs(self):
264        """Write out config files"""
265        for config_file, template in self._config_file_templates.iteritems():
266            with open(self.chroot_path(config_file), 'w') as f:
267                f.write(template % self._config_file_values)
268