Gentoo Archives: gentoo-portage-dev

From: "Michał Górny" <mgorny@g.o>
To: gentoo-portage-dev@l.g.o
Cc: "Michał Górny" <mgorny@g.o>
Subject: [gentoo-portage-dev] [PATCH v5] Support escaping network-sandbox through SOCKSv5 proxy
Date: Sun, 01 Feb 2015 00:33:33
Message-Id: 1422750800-13931-1-git-send-email-mgorny@gentoo.org
1 Add a minimal SOCKSv5-over-UNIX-socket proxy to Portage, and start it
2 whenever ebuilds are started with network-sandbox enabled. Pass the
3 socket address in PORTAGE_SOCKS5_PROXY and DISTCC_SOCKS_PROXY variables.
4 The proxy can be used to escape the network sandbox whenever network
5 access is really desired, e.g. in distcc.
6
7 The proxy is based on asynchronous I/O using the asyncio module.
8 Therefore, it requires the asyncio module that is built-in in Python 3.4
9 and available stand-alone for Python 3.3. Escaping the sandbox is not
10 supported with older versions of Python.
11
12 The proxy supports connecting to IPv6 & IPv4 TCP hosts. UDP and socket
13 binding are not supported. SOCKSv5 authentication schemes are not
14 supported (UNIX sockets provide a security layer).
15 ---
16 bin/save-ebuild-env.sh | 5 +-
17 bin/socks5-server.py | 219 +++++++++++++++++++++
18 man/ebuild.5 | 5 +
19 man/make.conf.5 | 7 +
20 .../package/ebuild/_config/special_env_vars.py | 2 +-
21 pym/portage/package/ebuild/doebuild.py | 11 ++
22 pym/portage/util/socks5.py | 81 ++++++++
23 7 files changed, 327 insertions(+), 3 deletions(-)
24 create mode 100644 bin/socks5-server.py
25 create mode 100644 pym/portage/util/socks5.py
26
27 diff --git a/bin/save-ebuild-env.sh b/bin/save-ebuild-env.sh
28 index c6bffb5..477ed28 100644
29 --- a/bin/save-ebuild-env.sh
30 +++ b/bin/save-ebuild-env.sh
31 @@ -92,7 +92,7 @@ __save_ebuild_env() {
32
33 # portage config variables and variables set directly by portage
34 unset ACCEPT_LICENSE BAD BRACKET BUILD_PREFIX COLS \
35 - DISTCC_DIR DISTDIR DOC_SYMLINKS_DIR \
36 + DISTCC_DIR DISTCC_SOCKS5_PROXY DISTDIR DOC_SYMLINKS_DIR \
37 EBUILD_FORCE_TEST EBUILD_MASTER_PID \
38 ECLASS_DEPTH ENDCOL FAKEROOTKEY \
39 GOOD HILITE HOME \
40 @@ -105,7 +105,8 @@ __save_ebuild_env() {
41 PORTAGE_DOHTML_WARN_ON_SKIPPED_FILES \
42 PORTAGE_NONFATAL PORTAGE_QUIET \
43 PORTAGE_SANDBOX_DENY PORTAGE_SANDBOX_PREDICT \
44 - PORTAGE_SANDBOX_READ PORTAGE_SANDBOX_WRITE PREROOTPATH \
45 + PORTAGE_SANDBOX_READ PORTAGE_SANDBOX_WRITE \
46 + PORTAGE_SOCKS5_PROXY PREROOTPATH \
47 QA_INTERCEPTORS \
48 RC_DEFAULT_INDENT RC_DOT_PATTERN RC_ENDCOL RC_INDENTATION \
49 ROOT ROOTPATH RPMDIR TEMP TMP TMPDIR USE_EXPAND \
50 diff --git a/bin/socks5-server.py b/bin/socks5-server.py
51 new file mode 100644
52 index 0000000..4efa0aa
53 --- /dev/null
54 +++ b/bin/socks5-server.py
55 @@ -0,0 +1,219 @@
56 +#!/usr/bin/env python
57 +# SOCKSv5 proxy server for network-sandbox
58 +# Copyright 2015 Gentoo Foundation
59 +# Distributed under the terms of the GNU General Public License v2
60 +
61 +import asyncio
62 +import errno
63 +import os
64 +import socket
65 +import struct
66 +import sys
67 +
68 +class Socks5Server(object):
69 + """
70 + An asynchronous SOCKSv5 server.
71 + """
72 +
73 + @asyncio.coroutine
74 + def handle_proxy_conn(self, reader, writer):
75 + """
76 + Handle incoming client connection. Perform SOCKSv5 request
77 + exchange, open a proxied connection and start relaying.
78 +
79 + @param reader: Read side of the socket
80 + @param writer: Write site of the socket
81 + """
82 +
83 + try:
84 + # SOCKS hello
85 + data = yield from reader.readexactly(2)
86 + vers, method_no = struct.unpack('!BB', data)
87 +
88 + if vers != 0x05:
89 + # disconnect on invalid packet -- we have no clue how
90 + # to reply in alien :)
91 + writer.close()
92 + return
93 +
94 + # ...and auth method list
95 + data = yield from reader.readexactly(method_no)
96 + for method in data:
97 + if method == 0x00:
98 + break
99 + else:
100 + # no supported method
101 + method = 0xFF
102 +
103 + # auth reply
104 + repl = struct.pack('!BB', 0x05, method)
105 + writer.write(repl)
106 + yield from writer.drain()
107 + if method == 0xFF:
108 + writer.close()
109 + return
110 +
111 + # request
112 + data = yield from reader.readexactly(4)
113 + vers, cmd, rsv, atyp = struct.unpack('!BBBB', data)
114 +
115 + if vers != 0x05 or rsv != 0x00:
116 + # disconnect on malformed packet
117 + self.close()
118 + return
119 +
120 + # figure out if we can handle it
121 + rpl = 0x00
122 + if cmd != 0x01: # CONNECT
123 + rpl = 0x07 # command not supported
124 + elif atyp == 0x01: # IPv4
125 + data = yield from reader.readexactly(4)
126 + addr = socket.inet_ntoa(data)
127 + elif atyp == 0x03: # domain name
128 + data = yield from reader.readexactly(1)
129 + addr_len, = struct.unpack('!B', data)
130 + addr = yield from reader.readexactly(addr_len)
131 + elif atyp == 0x04: # IPv6
132 + data = yield from reader.readexactly(16)
133 + addr = socket.inet_ntop(socket.AF_INET6, data)
134 + else:
135 + rpl = 0x08 # address type not supported
136 +
137 + # try to connect if we can handle it
138 + if rpl == 0x00:
139 + data = yield from reader.readexactly(2)
140 + port, = struct.unpack('!H', data)
141 +
142 + try:
143 + # establish proxied connection
144 + proxied_reader, proxied_writer = yield from asyncio.open_connection(
145 + addr, port)
146 + except (socket.gaierror, socket.herror):
147 + # DNS failure
148 + rpl = 0x04 # host unreachable
149 + except OSError as e:
150 + # connection failure
151 + if e.errno in (errno.ENETUNREACH, errno.ENETDOWN):
152 + rpl = 0x03 # network unreachable
153 + elif e.errno in (errno.EHOSTUNREACH, errno.EHOSTDOWN):
154 + rpl = 0x04 # host unreachable
155 + elif e.errno in (errno.ECONNREFUSED, errno.ETIMEDOUT):
156 + rpl = 0x05 # connection refused
157 + else:
158 + raise
159 + else:
160 + # get socket details that we can send back to the client
161 + # local address (sockname) in particular -- but we need
162 + # to ask for the whole socket since Python's sockaddr
163 + # does not list the family...
164 + sock = proxied_writer.get_extra_info('socket')
165 + addr = sock.getsockname()
166 + if sock.family == socket.AF_INET:
167 + host, port = addr
168 + bin_host = socket.inet_aton(host)
169 +
170 + repl_addr = struct.pack('!B4sH',
171 + 0x01, bin_host, port)
172 + elif sock.family == socket.AF_INET6:
173 + # discard flowinfo, scope_id
174 + host, port = addr[:2]
175 + bin_host = socket.inet_pton(sock.family, host)
176 +
177 + repl_addr = struct.pack('!B16sH',
178 + 0x04, bin_host, port)
179 +
180 + if rpl != 0x00:
181 + # fallback to 0.0.0.0:0
182 + repl_addr = struct.pack('!BLH', 0x01, 0x00000000, 0x0000)
183 +
184 + # reply to the request
185 + repl = struct.pack('!BBB', 0x05, rpl, 0x00)
186 + writer.write(repl + repl_addr)
187 + yield from writer.drain()
188 +
189 + # close if an error occured
190 + if rpl != 0x00:
191 + writer.close()
192 + return
193 +
194 + # otherwise, start two loops:
195 + # remote -> local...
196 + t = asyncio.async(self.handle_proxied_conn(
197 + proxied_reader, writer, asyncio.Task.current_task()))
198 +
199 + # and local -> remote...
200 + try:
201 + try:
202 + while True:
203 + data = yield from reader.read(4096)
204 + if data == b'':
205 + # client disconnected, stop relaying from
206 + # remote host
207 + t.cancel()
208 + break
209 +
210 + proxied_writer.write(data)
211 + yield from proxied_writer.drain()
212 + except OSError:
213 + # read or write failure
214 + t.cancel()
215 + except:
216 + t.cancel()
217 + raise
218 + finally:
219 + # always disconnect in the end :)
220 + proxied_writer.close()
221 + writer.close()
222 +
223 + except (OSError, asyncio.IncompleteReadError, asyncio.CancelledError):
224 + writer.close()
225 + return
226 + except:
227 + writer.close()
228 + raise
229 +
230 + @asyncio.coroutine
231 + def handle_proxied_conn(self, proxied_reader, writer, parent_task):
232 + """
233 + Handle the proxied connection. Relay incoming data
234 + to the client.
235 + """
236 +
237 + try:
238 + try:
239 + while True:
240 + data = yield from proxied_reader.read(4096)
241 + if data == b'':
242 + break
243 +
244 + writer.write(data)
245 + yield from writer.drain()
246 + finally:
247 + parent_task.cancel()
248 + except (OSError, asyncio.CancelledError):
249 + return
250 +
251 +
252 +if __name__ == '__main__':
253 + if len(sys.argv) != 2:
254 + print('Usage: %s <socket-path>' % sys.argv[0])
255 + sys.exit(1)
256 +
257 + loop = asyncio.get_event_loop()
258 + s = Socks5Server()
259 + server = loop.run_until_complete(
260 + asyncio.start_unix_server(s.handle_proxy_conn, sys.argv[1], loop=loop))
261 +
262 + ret = 0
263 + try:
264 + try:
265 + loop.run_forever()
266 + except KeyboardInterrupt:
267 + pass
268 + except:
269 + ret = 1
270 + finally:
271 + server.close()
272 + loop.run_until_complete(server.wait_closed())
273 + loop.close()
274 + os.unlink(sys.argv[1])
275 diff --git a/man/ebuild.5 b/man/ebuild.5
276 index b587264..3f35180 100644
277 --- a/man/ebuild.5
278 +++ b/man/ebuild.5
279 @@ -484,6 +484,11 @@ source source\-build which is scheduled for merge
280 Contains the path of the build log. If \fBPORT_LOGDIR\fR variable is unset then
281 PORTAGE_LOG_FILE=\fI"${T}/build.log"\fR.
282 .TP
283 +.B PORTAGE_SOCKS5_PROXY
284 +Contains the UNIX socket path to SOCKSv5 proxy providing host network
285 +access. Available only when running inside network\-sandbox and a proxy
286 +is available (see network\-sandbox in \fBmake.conf\fR(5)).
287 +.TP
288 .B REPLACED_BY_VERSION
289 Beginning with \fBEAPI 4\fR, the REPLACED_BY_VERSION variable can be
290 used in pkg_prerm and pkg_postrm to query the package version that
291 diff --git a/man/make.conf.5 b/man/make.conf.5
292 index ed5fc78..84b7191 100644
293 --- a/man/make.conf.5
294 +++ b/man/make.conf.5
295 @@ -436,6 +436,13 @@ from putting 64bit libraries into anything other than (/usr)/lib64.
296 .B network\-sandbox
297 Isolate the ebuild phase functions from host network interfaces.
298 Supported only on Linux. Requires network namespace support in kernel.
299 +
300 +If asyncio Python module is available (requires Python 3.3, built-in
301 +since Python 3.4) Portage will additionally spawn an isolated SOCKSv5
302 +proxy on UNIX socket. The socket address will be exported
303 +as PORTAGE_SOCKS5_PROXY and the processes running inside the sandbox
304 +can use it to access host's network when desired. Portage automatically
305 +configures new enough distcc to use the proxy.
306 .TP
307 .B news
308 Enable GLEP 42 news support. See
309 diff --git a/pym/portage/package/ebuild/_config/special_env_vars.py b/pym/portage/package/ebuild/_config/special_env_vars.py
310 index 6bb3c95..905d5e7 100644
311 --- a/pym/portage/package/ebuild/_config/special_env_vars.py
312 +++ b/pym/portage/package/ebuild/_config/special_env_vars.py
313 @@ -71,7 +71,7 @@ environ_whitelist += [
314 "PORTAGE_PYM_PATH", "PORTAGE_PYTHON",
315 "PORTAGE_PYTHONPATH", "PORTAGE_QUIET",
316 "PORTAGE_REPO_NAME", "PORTAGE_REPOSITORIES", "PORTAGE_RESTRICT",
317 - "PORTAGE_SIGPIPE_STATUS",
318 + "PORTAGE_SIGPIPE_STATUS", "PORTAGE_SOCKS5_PROXY",
319 "PORTAGE_TMPDIR", "PORTAGE_UPDATE_ENV", "PORTAGE_USERNAME",
320 "PORTAGE_VERBOSE", "PORTAGE_WORKDIR_MODE", "PORTAGE_XATTR_EXCLUDE",
321 "PORTDIR", "PORTDIR_OVERLAY", "PREROOTPATH",
322 diff --git a/pym/portage/package/ebuild/doebuild.py b/pym/portage/package/ebuild/doebuild.py
323 index 791b5c3..6329b62 100644
324 --- a/pym/portage/package/ebuild/doebuild.py
325 +++ b/pym/portage/package/ebuild/doebuild.py
326 @@ -68,6 +68,7 @@ from portage.util import apply_recursive_permissions, \
327 writemsg, writemsg_stdout, write_atomic
328 from portage.util.cpuinfo import get_cpu_count
329 from portage.util.lafilefixer import rewrite_lafile
330 +from portage.util.socks5 import get_socks5_proxy
331 from portage.versions import _pkgsplit
332 from _emerge.BinpkgEnvExtractor import BinpkgEnvExtractor
333 from _emerge.EbuildBuildDir import EbuildBuildDir
334 @@ -1487,6 +1488,16 @@ def spawn(mystring, mysettings, debug=False, free=False, droppriv=False,
335 keywords['unshare_net'] = not networked
336 keywords['unshare_ipc'] = not ipc
337
338 + if not networked:
339 + # Provide a SOCKS5-over-UNIX-socket proxy to escape sandbox
340 + try:
341 + proxy = get_socks5_proxy(mysettings)
342 + except NotImplementedError:
343 + pass
344 + else:
345 + mysettings['PORTAGE_SOCKS5_PROXY'] = proxy
346 + mysettings['DISTCC_SOCKS_PROXY'] = proxy
347 +
348 # TODO: Enable fakeroot to be used together with droppriv. The
349 # fake ownership/permissions will have to be converted to real
350 # permissions in the merge phase.
351 diff --git a/pym/portage/util/socks5.py b/pym/portage/util/socks5.py
352 new file mode 100644
353 index 0000000..278986d
354 --- /dev/null
355 +++ b/pym/portage/util/socks5.py
356 @@ -0,0 +1,81 @@
357 +# SOCKSv5 proxy manager for network-sandbox
358 +# Copyright 2015 Gentoo Foundation
359 +# Distributed under the terms of the GNU General Public License v2
360 +
361 +import os
362 +import signal
363 +
364 +from portage import _python_interpreter
365 +from portage.data import portage_gid, portage_uid, userpriv_groups
366 +from portage.process import atexit_register, spawn
367 +
368 +
369 +class ProxyManager(object):
370 + """
371 + A class to start and control a single running SOCKSv5 server process
372 + for Portage.
373 + """
374 +
375 + def __init__(self):
376 + self.socket_path = None
377 + self._pids = []
378 +
379 + def start(self, settings):
380 + """
381 + Start the SOCKSv5 server.
382 +
383 + @param settings: Portage settings instance (used to determine
384 + paths)
385 + @type settings: portage.config
386 + """
387 + try:
388 + import asyncio
389 + except ImportError:
390 + raise NotImplementedError('SOCKSv5 proxy requires asyncio module')
391 +
392 + self.socket_path = os.path.join(settings['PORTAGE_TMPDIR'],
393 + '.portage.%d.net.sock' % os.getpid())
394 + server_bin = os.path.join(settings['PORTAGE_BIN_PATH'], 'socks5-server.py')
395 + self._pids = spawn([_python_interpreter, server_bin, self.socket_path],
396 + returnpid=True, uid=portage_uid, gid=portage_gid,
397 + groups=userpriv_groups, umask=0o077)
398 +
399 + def stop(self):
400 + """
401 + Stop the SOCKSv5 server.
402 + """
403 + for p in self._pids:
404 + os.kill(p, signal.SIGINT)
405 + os.waitpid(p, 0)
406 +
407 + self.socket_path = None
408 + self._pids = []
409 +
410 + def is_running(self):
411 + """
412 + Check whether the SOCKSv5 server is running.
413 +
414 + @return: True if the server is running, False otherwise
415 + """
416 + return self.socket_path is not None
417 +
418 +
419 +proxy = ProxyManager()
420 +
421 +
422 +def get_socks5_proxy(settings):
423 + """
424 + Get UNIX socket path for a SOCKSv5 proxy. A new proxy is started if
425 + one isn't running yet, and an atexit event is added to stop the proxy
426 + on exit.
427 +
428 + @param settings: Portage settings instance (used to determine paths)
429 + @type settings: portage.config
430 + @return: (string) UNIX socket path
431 + """
432 +
433 + if not proxy.is_running():
434 + proxy.start(settings)
435 + atexit_register(proxy.stop)
436 +
437 + return proxy.socket_path
438 --
439 2.2.2

Replies