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