network_chroot.py revision 436adf9c1b9404697da462e0048ac6d2cf046554
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      - Manually mount /dev/pts, since the bind mount that the
29        minijail0 command creates is read-only and xl2tpd is unable
30        to perform operations like chown() on it which is a fatal error.
31      - We can now configure the network interface with an address.
32      - At this point, we can now start any user-requested server
33        processes.
34    """
35    BIND_ROOT_DIRECTORIES = ('bin', 'dev', 'lib', 'lib32', 'lib64',
36                             'proc', 'sbin', 'sys', 'usr', 'usr/local')
37    ROOT_DIRECTORIES = ('etc',  'tmp', 'var', 'var/log', 'var/run')
38    STARTUP = 'etc/chroot_startup.sh'
39    STARTUP_DELAY_SECONDS = 5
40    STARTUP_PID_FILE = 'var/run/vpn_startup.pid'
41    STARTUP_SLEEPER_PID_FILE = 'var/run/vpn_sleeper.pid'
42
43    # These flags must track those given at boot in /sbin/chromeos_startup,
44    # otherwise we will end up changing the mount flags for the root
45    # filesystem's /dev/pts.
46    DEV_PTS_FLAGS = 'noexec,nosuid,gid=5,mode=0620'
47
48    COPIED_CONFIG_FILES = [
49        'etc/ld.so.cache'
50    ]
51    CONFIG_FILE_TEMPLATES = {
52        STARTUP:
53            '#!/bin/sh\n'
54            'exec > /var/log/startup.log 2>&1\n'
55            'set -x\n'
56            'echo $$ > /%(startup-pidfile)s\n'
57            'sleep %(startup-delay-seconds)d &\n'
58            'echo $! > /%(sleeper-pidfile)s &\n'
59            'wait\n'
60            'mount -t devpts -o %(dev-pts-flags)s devpts /dev/pts\n'
61            'ip addr add %(local-ip-and-prefix)s dev %(local-interface-name)s\n'
62            'ip link set %(local-interface-name)s up\n'
63    }
64    CONFIG_FILE_VALUES = {
65        'dev-pts-flags': DEV_PTS_FLAGS,
66        'sleeper-pidfile': STARTUP_SLEEPER_PID_FILE,
67        'startup-delay-seconds': STARTUP_DELAY_SECONDS,
68        'startup-pidfile': STARTUP_PID_FILE
69    }
70
71    def __init__(self, interface, address, prefix):
72        self._interface = interface
73
74        # Copy these values from the class-static since specific instances
75        # of this class are allowed to modify their contents.
76        self._root_directories = list(self.ROOT_DIRECTORIES)
77        self._copied_config_files = list(self.COPIED_CONFIG_FILES)
78        self._config_file_templates = self.CONFIG_FILE_TEMPLATES.copy()
79        self._config_file_values = self.CONFIG_FILE_VALUES.copy()
80
81        self._config_file_values.update({
82            'local-interface-name': interface,
83            'local-ip': address,
84            'local-ip-and-prefix': '%s/%d' % (address, prefix)
85        })
86
87
88    def startup(self):
89        """Create the chroot and start user processes."""
90        self.make_chroot()
91        self.write_configs()
92        self.run(['/bin/bash', os.path.join('/', self.STARTUP), '&'])
93        self.move_interface_to_chroot_namespace()
94        self.kill_pid_file(self.STARTUP_SLEEPER_PID_FILE)
95
96
97    def shutdown(self):
98        """Remove the chroot filesystem in which the VPN server was running"""
99        # TODO(pstew): Some processes take a while to exit, which will cause
100        # the cleanup below to fail to complete successfully...
101        time.sleep(10)
102        utils.system_output('rm -rf --one-file-system %s' % self._temp_dir,
103                            ignore_status=True)
104
105
106    def add_config_templates(self, template_dict):
107        """Add a filename-content dict to the set of templates for the chroot
108
109        @param template_dict dict containing filename-content pairs for
110            templates to be applied to the chroot.  The keys to this dict
111            should not contain a leading '/'.
112
113        """
114        self._config_file_templates.update(template_dict)
115
116
117    def add_config_values(self, value_dict):
118        """Add a name-value dict to the set of values for the config template
119
120        @param value_dict dict containing key-value pairs of values that will
121            be applied to the config file templates.
122
123        """
124        self._config_file_values.update(value_dict)
125
126
127    def add_root_directories(self, directories):
128        """Add |directories| to the set created within the chroot.
129
130        @param directories list/tuple containing a list of directories to
131            be created in the chroot.  These elements should not contain a
132            leading '/'.
133
134        """
135        self._root_directories += directories
136
137
138    def add_startup_command(self, command):
139        """Add a command to the script run when the chroot starts up.
140
141        @param command string containing the command line to run.
142
143        """
144        self._config_file_templates[self.STARTUP] += '%s\n' % command
145
146
147    def get_log_contents(self):
148        """Return the logfiles from the chroot."""
149        return utils.system_output("head -10000 %s" %
150                                   self.chroot_path("var/log/*"))
151
152
153    def chroot_path(self, path):
154        """Returns the the path within the chroot for |path|.
155
156        @param path string filename within the choot.  This should not
157            contain a leading '/'.
158
159        """
160        return os.path.join(self._temp_dir, path.lstrip('/'))
161
162
163    def get_pid_file(self, pid_file, missing_ok=False):
164        """Returns the integer contents of |pid_file| in the chroot.
165
166        @param pid_file string containing the filename within the choot
167            to read and convert to an integer.  This should not contain a
168            leading '/'.
169        @param missing_ok bool indicating whether exceptions due to failure
170            to open the pid file should be caught.  If true a missing pid
171            file will cause this method to return 0.  If false, a missing
172            pid file will cause an exception.
173
174        """
175        chroot_pid_file = self.chroot_path(pid_file)
176        try:
177            with open(chroot_pid_file) as f:
178                return int(f.read())
179        except IOError, e:
180            if not missing_ok or e.errno != errno.ENOENT:
181                raise e
182
183            return 0
184
185
186    def kill_pid_file(self, pid_file, missing_ok=False):
187        """Kills the process belonging to |pid_file| in the chroot.
188
189        @param pid_file string filename within the chroot to gain the process ID
190            which this method will kill.
191        @param missing_ok bool indicating whether a missing pid file is okay,
192            and should be ignored.
193
194        """
195        pid = self.get_pid_file(pid_file, missing_ok)
196        if missing_ok and pid == 0:
197            return
198        utils.system('kill %d' % pid, ignore_status=True)
199
200
201    def make_chroot(self):
202        """Make a chroot filesystem."""
203        self._temp_dir = utils.system_output('mktemp -d /tmp/chroot.XXXXXXXXX')
204        utils.system('chmod go+rX %s' % self._temp_dir)
205        for rootdir in self._root_directories:
206            os.mkdir(self.chroot_path(rootdir))
207
208        self._jail_args = []
209        for rootdir in self.BIND_ROOT_DIRECTORIES:
210            src_path = os.path.join('/', rootdir)
211            dst_path = self.chroot_path(rootdir)
212            if not os.path.exists(src_path):
213                continue
214            elif os.path.islink(src_path):
215                link_path = os.readlink(src_path)
216                os.symlink(link_path, dst_path)
217            else:
218                os.mkdir(dst_path)
219                self._jail_args += [ '-b', '%s,%s' % (src_path, src_path) ]
220
221        for config_file in self._copied_config_files:
222            src_path = os.path.join('/', config_file)
223            dst_path = self.chroot_path(config_file)
224            if os.path.exists(src_path):
225                shutil.copyfile(src_path, dst_path)
226
227
228    def move_interface_to_chroot_namespace(self):
229        """Move network interface to the network namespace of the server."""
230        utils.system('ip link set %s netns %d' %
231                     (self._interface,
232                      self.get_pid_file(self.STARTUP_PID_FILE)))
233
234
235    def run(self, args):
236        """Run a command in a chroot, within a separate network namespace.
237
238        @param args list containing the command line arguments to run.
239
240        """
241        utils.system('minijail0 -e -C %s %s' %
242                     (self._temp_dir, ' '.join(self._jail_args + args)))
243
244
245    def write_configs(self):
246        """Write out config files"""
247        for config_file, template in self._config_file_templates.iteritems():
248            with open(self.chroot_path(config_file), 'w') as f:
249                f.write(template % self._config_file_values)
250