582 lines
30 KiB
Python
582 lines
30 KiB
Python
"""Executed ONLY by the isolated backend test. Never uses the host system bus."""
|
|
import array
|
|
import fcntl
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import select
|
|
import socket
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import unittest
|
|
from urllib.parse import unquote
|
|
from unittest.mock import patch, Mock
|
|
|
|
assert os.environ.get('ARCHY_TEST_ISOLATED') == '1', 'isolated runner required'
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
import gi
|
|
gi.require_version('Gio', '2.0')
|
|
from gi.repository import Gio, GLib
|
|
import c01_network_settings_nm as nm
|
|
import c01_network_settings_owner as owner
|
|
import c01_network_settings_watch as watch
|
|
|
|
V = GLib.Variant
|
|
D = nm.ROOT + '/Devices/1'
|
|
P = nm.ROOT + '/Settings/1'
|
|
A = nm.ROOT + '/ActiveConnection/1'
|
|
IP4 = nm.ROOT + '/IP4Config/1'
|
|
AP = nm.ROOT + '/AccessPoint/1'
|
|
|
|
|
|
class FakeNM:
|
|
"""Actual disposable message bus service, not a mocked adapter method."""
|
|
def __init__(self):
|
|
self.test_bus = Gio.TestDBus.new(Gio.TestDBusFlags.NONE)
|
|
self.test_bus.up()
|
|
flags = Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION
|
|
address = self.test_bus.get_bus_address()
|
|
self.server = Gio.DBusConnection.new_for_address_sync(address, flags, None, None)
|
|
self.client = Gio.DBusConnection.new_for_address_sync(address, flags, None, None)
|
|
self.server.call_sync('org.freedesktop.DBus', '/org/freedesktop/DBus', 'org.freedesktop.DBus', 'RequestName', V('(su)', (nm.SERVICE, 0)), None, Gio.DBusCallFlags.NONE, 1000, None)
|
|
self.version, self.applied_version, self.device_type = 3, 7, 1
|
|
self.calls, self.fail_method, self.bad_readback = [], None, False
|
|
self.pending_state_reads, self.state_reads = 0, 0
|
|
self.ap_ssid, self.ap_rsn = b'fixture-only', 0x100
|
|
self.settings = {
|
|
'connection': {'id': V('s', 'operator'), 'uuid': V('s', '12345678-1234-1234-1234-123456789abc'), 'type': V('s', '802-3-ethernet')},
|
|
'ipv4': {'method': V('s', 'manual'), 'address-data': V('aa{sv}', [{'address': V('s', '192.0.2.8'), 'prefix': V('u', 24)}]), 'gateway': V('s', '192.0.2.1'), 'dns': V('au', [0x09090909]), 'ignore-auto-dns': V('b', True), 'route-metric': V('x', 77), 'dns-search': V('as', ['operator.example'])},
|
|
'ipv6': {'method': V('s', 'auto')},
|
|
'802-3-ethernet': {'mtu': V('u', 1400)}}
|
|
self.applied = dict(self.settings)
|
|
self.registrations = []
|
|
self.register(nm.ROOT, nm.SERVICE, '''
|
|
<method name="GetDevices"><arg type="ao" direction="out"/></method>
|
|
<method name="ActivateConnection"><arg type="o" direction="in"/><arg type="o" direction="in"/><arg type="o" direction="in"/><arg type="o" direction="out"/></method>''')
|
|
self.register(nm.ROOT + '/Settings', nm.SERVICE + '.Settings', '''
|
|
<method name="ListConnections"><arg type="ao" direction="out"/></method>
|
|
<method name="AddConnection2"><arg type="a{sa{sv}}" direction="in"/><arg type="u" direction="in"/><arg type="a{sv}" direction="in"/><arg type="o" direction="out"/><arg type="a{sv}" direction="out"/></method>''')
|
|
self.register(D, nm.DEVICE, '''
|
|
<method name="GetAppliedConnection"><arg type="u" direction="in"/><arg type="a{sa{sv}}" direction="out"/><arg type="t" direction="out"/></method>
|
|
<method name="Reapply"><arg type="a{sa{sv}}" direction="in"/><arg type="t" direction="in"/><arg type="u" direction="in"/></method>
|
|
<property name="Interface" type="s" access="read"/><property name="DeviceType" type="u" access="read"/>
|
|
<property name="Managed" type="b" access="read"/><property name="Real" type="b" access="read"/>
|
|
<property name="Software" type="b" access="read"/><property name="ActiveConnection" type="o" access="read"/>
|
|
<property name="State" type="u" access="read"/><property name="Ip4Config" type="o" access="read"/>''')
|
|
self.register(IP4, nm.SERVICE + '.IP4Config', '''
|
|
<property name="AddressData" type="aa{sv}" access="read"/><property name="Gateway" type="s" access="read"/>''')
|
|
self.register(D, nm.DEVICE + '.Wireless', '''
|
|
<method name="GetAllAccessPoints"><arg type="ao" direction="out"/></method>''')
|
|
self.register(AP, nm.SERVICE + '.AccessPoint', '''
|
|
<property name="Ssid" type="ay" access="read"/><property name="RsnFlags" type="u" access="read"/>''')
|
|
self.register(P, nm.CONNECTION, '''
|
|
<method name="GetSettings"><arg type="a{sa{sv}}" direction="out"/></method>
|
|
<method name="Update2"><arg type="a{sa{sv}}" direction="in"/><arg type="u" direction="in"/><arg type="a{sv}" direction="in"/><arg type="a{sv}" direction="out"/></method>
|
|
<property name="VersionId" type="t" access="read"/>''')
|
|
self.register(A, nm.SERVICE + '.Connection.Active', '''
|
|
<property name="Connection" type="o" access="read"/><property name="State" type="u" access="read"/>''')
|
|
self.loop = GLib.MainLoop()
|
|
self.thread = threading.Thread(target=self.loop.run)
|
|
self.thread.start()
|
|
|
|
def register(self, path, interface, xml):
|
|
info = Gio.DBusNodeInfo.new_for_xml('<node><interface name="' + interface + '">' + xml + '</interface></node>')
|
|
self.registrations.append(self.server.register_object(path, info.interfaces[0], self.method, self.prop, None))
|
|
|
|
def prop(self, connection, sender, path, interface, name):
|
|
if name == 'Ssid':
|
|
return V('ay', list(self.ap_ssid))
|
|
if name == 'RsnFlags':
|
|
return V('u', self.ap_rsn)
|
|
if name == 'State' and path == D:
|
|
self.state_reads += 1
|
|
if self.pending_state_reads:
|
|
self.pending_state_reads -= 1
|
|
return V('u', 70)
|
|
return V('u', 100)
|
|
if name == 'Ip4Config':
|
|
return V('o', IP4)
|
|
if name == 'AddressData':
|
|
group = self.applied['ipv4']
|
|
if group['method'].unpack() == 'manual':
|
|
return group['address-data']
|
|
return V('aa{sv}', [{'address': V('s', '198.51.100.9'), 'prefix': V('u', 24)}])
|
|
if name == 'Gateway':
|
|
return self.applied['ipv4'].get('gateway', V('s', ''))
|
|
return {'Interface': V('s', 'eth0'), 'DeviceType': V('u', self.device_type), 'Managed': V('b', True), 'Real': V('b', True), 'Software': V('b', False), 'ActiveConnection': V('o', A), 'Connection': V('o', P), 'State': V('u', 2), 'VersionId': V('t', self.version)}[name]
|
|
|
|
def method(self, connection, sender, path, interface, method, args, invocation):
|
|
self.calls.append(method)
|
|
if method == self.fail_method:
|
|
invocation.return_dbus_error('org.freedesktop.NetworkManager.Error.Failed', 'private NM error must not escape')
|
|
return
|
|
try:
|
|
if method == 'GetDevices':
|
|
result = V('(ao)', ([D],))
|
|
elif method == 'GetAllAccessPoints':
|
|
result = V('(ao)', ([AP],))
|
|
elif method == 'ListConnections':
|
|
result = V('(ao)', ([P],))
|
|
elif method == 'GetSettings':
|
|
public = {group: dict(values) for group, values in self.settings.items()}
|
|
public.get('802-11-wireless-security', {}).pop('psk', None)
|
|
result = V('(a{sa{sv}})', (public,))
|
|
elif method == 'GetAppliedConnection':
|
|
result = V('(a{sa{sv}}t)', (self.applied, self.applied_version))
|
|
elif method == 'Update2':
|
|
assert args.unpack()[2]['version-id'] == self.version
|
|
self.settings = nm.variant_settings(args.get_child_value(0))
|
|
self.version += 1
|
|
result = V('(a{sv})', ({},))
|
|
elif method == 'Reapply':
|
|
assert args.unpack()[1] == self.applied_version
|
|
self.applied = nm.variant_settings(args.get_child_value(0))
|
|
if self.bad_readback:
|
|
self.applied['ipv4']['method'] = V('s', 'disabled')
|
|
self.applied_version += 1
|
|
result = V('()', ())
|
|
elif method == 'AddConnection2':
|
|
self.settings = nm.variant_settings(args.get_child_value(0))
|
|
self.version += 1
|
|
result = V('(oa{sv})', (P, {}))
|
|
elif method == 'ActivateConnection':
|
|
assert args.unpack() == (P, D, '/')
|
|
self.applied = self.settings
|
|
result = V('(o)', (A,))
|
|
else:
|
|
raise AssertionError(method)
|
|
invocation.return_value(result)
|
|
except BaseException:
|
|
invocation.return_dbus_error('org.freedesktop.NetworkManager.Error.Failed', 'fixture assertion')
|
|
|
|
def close(self):
|
|
self.client.close_sync(None)
|
|
self.server.close_sync(None)
|
|
self.loop.quit()
|
|
self.thread.join(2)
|
|
self.test_bus.down()
|
|
|
|
|
|
class ExecutorTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.bus = FakeNM()
|
|
self.directory = tempfile.TemporaryDirectory()
|
|
self.fd = os.open(self.directory.name, os.O_RDONLY | os.O_DIRECTORY)
|
|
self.journal = owner.Journal(self.fd)
|
|
self.owner = owner.Owner(self.journal, lambda: nm.NetworkManager(self.bus.client))
|
|
|
|
def tearDown(self):
|
|
if self.owner.thread:
|
|
self.owner.thread.join(3)
|
|
self.bus.close()
|
|
os.close(self.fd)
|
|
self.directory.cleanup()
|
|
|
|
def execute(self, mutation):
|
|
first = self.owner.submit(mutation)
|
|
self.owner.thread.join(5)
|
|
self.assertFalse(self.owner.thread.is_alive())
|
|
return self.owner.status(first['ticket'])['phase']
|
|
|
|
def test_dhcp_actual_bus_preserves_operator_dns_routes_and_mtu(self):
|
|
self.assertEqual(self.execute({'Ethernet': {'interface': 'eth0', 'ipv4': 'Dhcp'}}), 'Succeeded')
|
|
settings = self.bus.settings
|
|
self.assertEqual(settings['ipv4']['method'].unpack(), 'auto')
|
|
self.assertEqual(settings['ipv4']['address-data'].unpack(), [])
|
|
self.assertEqual(settings['ipv4']['gateway'].unpack(), '')
|
|
self.assertEqual(settings['ipv4']['dns'].unpack(), [0x09090909])
|
|
self.assertEqual(settings['ipv4']['route-metric'].unpack(), 77)
|
|
self.assertEqual(settings['802-3-ethernet']['mtu'].unpack(), 1400)
|
|
self.assertEqual(self.bus.calls.count('Update2'), 1)
|
|
self.assertFalse(self.journal.blocked())
|
|
|
|
def test_static_and_mixed_dns_actual_bus(self):
|
|
mutation = {'Ethernet': {'interface': 'eth0', 'ipv4': {'Static': {'address': '192.0.2.2', 'prefix': 24, 'gateway': '192.0.2.1', 'dns': {'Servers': ['1.1.1.1', '2606:4700:4700::1111']}}}}}
|
|
self.assertEqual(self.execute(mutation), 'Succeeded')
|
|
self.assertEqual(self.bus.settings['ipv4']['address-data'].unpack()[0]['address'], '192.0.2.2')
|
|
self.assertEqual(self.bus.settings['ipv6']['dns'].unpack()[0][-2:], [17, 17])
|
|
self.assertTrue(self.bus.settings['ipv6']['ignore-auto-dns'].unpack())
|
|
|
|
def test_ethernet_waits_for_device_activation_and_address(self):
|
|
self.bus.pending_state_reads = 2
|
|
self.assertEqual(self.execute({'Ethernet': {'interface': 'eth0', 'ipv4': 'Dhcp'}}), 'Succeeded')
|
|
self.assertGreaterEqual(self.bus.state_reads, 3)
|
|
|
|
def test_system_dns_clears_both_families_preserves_search(self):
|
|
self.assertEqual(self.execute({'Dns': {'policy': 'Automatic'}}), 'Succeeded')
|
|
for group in ('ipv4', 'ipv6'):
|
|
self.assertEqual(self.bus.settings[group]['dns'].unpack(), [])
|
|
self.assertFalse(self.bus.settings[group]['ignore-auto-dns'].unpack())
|
|
self.assertEqual(self.bus.settings['ipv4']['dns-search'].unpack(), ['operator.example'])
|
|
|
|
def test_wifi_secret_dbus_profile_and_activation(self):
|
|
self.bus.device_type = 2
|
|
mutation = {'Wifi': {'ssid': 'fixture-only', 'password': 'fixture-secret'}}
|
|
self.assertEqual(self.execute(mutation), 'Succeeded')
|
|
self.assertIn('AddConnection2', self.bus.calls)
|
|
self.assertIn('ActivateConnection', self.bus.calls)
|
|
self.assertEqual(self.bus.settings['802-11-wireless-security']['psk'].unpack(), 'fixture-secret')
|
|
self.assertEqual(mutation['Wifi']['password'], '')
|
|
record = Path(self.directory.name, 'operation.json').read_text()
|
|
self.assertNotIn('fixture-secret', record)
|
|
self.assertNotIn('fixture-only', record)
|
|
|
|
def test_operator_wifi_profile_not_deleted_or_overwritten(self):
|
|
self.bus.device_type = 2
|
|
self.bus.settings['802-11-wireless'] = {'ssid': V('ay', list(b'operator-wifi'))}
|
|
self.assertEqual(self.execute({'Wifi': {'ssid': 'operator-wifi', 'password': 'fixture-secret'}}), 'Rejected')
|
|
self.assertNotIn('Update2', self.bus.calls)
|
|
self.assertNotIn('AddConnection2', self.bus.calls)
|
|
self.assertFalse(self.journal.blocked())
|
|
|
|
def test_wpa3_long_password_selects_sae_and_preserves_full_secret(self):
|
|
self.bus.device_type, self.bus.ap_rsn = 2, 0x400
|
|
secret = 'owner-chosen long passphrase ' * 5
|
|
mutation = {'Wifi': {'ssid': 'fixture-only', 'password': secret}}
|
|
self.assertEqual(self.execute(mutation), 'Succeeded')
|
|
security = self.bus.settings['802-11-wireless-security']
|
|
self.assertEqual(security['key-mgmt'].unpack(), 'sae')
|
|
self.assertEqual(security['psk'].unpack(), secret)
|
|
self.assertEqual(security['pmf'].unpack(), 3)
|
|
self.assertEqual(mutation['Wifi']['password'], '')
|
|
|
|
def test_wpa2_long_nonhex_password_rejected_before_profile_mutation(self):
|
|
self.bus.device_type = 2
|
|
self.assertEqual(self.execute({'Wifi': {'ssid': 'fixture-only', 'password': 'z' * 80}}), 'Rejected')
|
|
self.assertNotIn('AddConnection2', self.bus.calls)
|
|
self.assertNotIn('ActivateConnection', self.bus.calls)
|
|
self.assertFalse(self.journal.blocked())
|
|
|
|
def test_sae_advertised_by_another_ssid_does_not_authorize_long_wpa2_password(self):
|
|
self.bus.device_type, self.bus.ap_rsn, self.bus.ap_ssid = 2, 0x400, b'another-network'
|
|
self.assertEqual(self.execute({'Wifi': {'ssid': 'fixture-only', 'password': 'z' * 80}}), 'Rejected')
|
|
self.assertNotIn('AddConnection2', self.bus.calls)
|
|
|
|
def test_wpa2_raw_hex_key_is_preserved(self):
|
|
self.bus.device_type = 2
|
|
self.assertEqual(self.execute({'Wifi': {'ssid': 'fixture-only', 'password': 'a' * 64}}), 'Succeeded')
|
|
self.assertEqual(self.bus.settings['802-11-wireless-security']['key-mgmt'].unpack(), 'wpa-psk')
|
|
self.assertEqual(self.bus.settings['802-11-wireless-security']['psk'].unpack(), 'a' * 64)
|
|
|
|
def test_saved_sae_profile_never_downgrades_for_short_password(self):
|
|
self.bus.device_type = 2
|
|
self.bus.settings['802-11-wireless'] = {'ssid': V('ay', list(b'owned-wifi'))}
|
|
self.bus.settings['user'] = {'data': V('a{ss}', {'archipelago.network-settings': '1'})}
|
|
self.bus.settings['802-11-wireless-security'] = {'key-mgmt': V('s', 'sae')}
|
|
self.assertEqual(self.execute({'Wifi': {'ssid': 'owned-wifi', 'password': 'short'}}), 'Succeeded')
|
|
self.assertEqual(self.bus.settings['802-11-wireless-security']['key-mgmt'].unpack(), 'sae')
|
|
|
|
def test_owned_wifi_preserves_explicit_security_and_refuses_downgrade(self):
|
|
self.bus.device_type = 2
|
|
self.bus.settings['802-11-wireless'] = {'ssid': V('ay', list(b'owned-wifi'))}
|
|
self.bus.settings['user'] = {'data': V('a{ss}', {'archipelago.network-settings': '1'})}
|
|
self.bus.settings['802-11-wireless-security'] = {'key-mgmt': V('s', 'wpa-psk'), 'pmf': V('u', 3)}
|
|
self.assertEqual(self.execute({'Wifi': {'ssid': 'owned-wifi', 'password': ''}}), 'Rejected')
|
|
self.assertNotIn('Update2', self.bus.calls)
|
|
self.assertEqual(self.execute({'Wifi': {'ssid': 'owned-wifi', 'password': 'fixture-secret'}}), 'Succeeded')
|
|
self.assertEqual(self.bus.settings['802-11-wireless-security']['pmf'].unpack(), 3)
|
|
|
|
def test_partial_failure_fences_restart_and_never_retries(self):
|
|
self.bus.fail_method = 'Reapply'
|
|
self.assertEqual(self.execute({'Ethernet': {'interface': 'eth0', 'ipv4': 'Dhcp'}}), 'Unknown')
|
|
restored = owner.Owner(owner.Journal(self.fd), lambda: nm.NetworkManager(self.bus.client))
|
|
self.assertEqual(restored.submit({'Dns': {'policy': 'Automatic'}})['phase'], 'Rejected')
|
|
self.assertEqual(self.bus.calls.count('Update2'), 1)
|
|
self.assertNotIn('private NM error', Path(self.directory.name, 'operation.json').read_text())
|
|
|
|
def test_readback_mismatch_is_not_success(self):
|
|
self.bus.bad_readback = True
|
|
self.assertEqual(self.execute({'Ethernet': {'interface': 'eth0', 'ipv4': 'Dhcp'}}), 'Unknown')
|
|
self.assertTrue(self.journal.blocked())
|
|
|
|
def test_expired_before_effect_no_write(self):
|
|
adapter = nm.NetworkManager(self.bus.client)
|
|
with self.assertRaises(ValueError):
|
|
adapter.execute({'Dns': {'policy': 'Automatic'}}, 0, lambda paths: self.fail('late mutation'), lambda: None)
|
|
self.assertNotIn('Update2', self.bus.calls)
|
|
|
|
def test_real_nonroot_peer_owner_loop_and_supervisor_watch(self):
|
|
# Real nonroot connect(), not a root-created application socketpair.
|
|
self.bus.pending_state_reads = 2
|
|
address = '\0network-settings-test-' + os.urandom(16).hex()
|
|
listener = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET)
|
|
listener.setsockopt(socket.SOL_SOCKET, socket.SO_PASSCRED, 1)
|
|
listener.bind(address)
|
|
listener.listen(1)
|
|
listener.settimeout(3)
|
|
ready_read, ready_write = os.pipe()
|
|
app_code = '''import os,socket,json,time,sys
|
|
os.setgroups([]); os.setgid(65534); os.setuid(65534)
|
|
s=socket.socket(socket.AF_UNIX,socket.SOCK_SEQPACKET)
|
|
s.connect(bytes.fromhex(sys.argv[1]).decode())
|
|
if len(sys.argv)>2:
|
|
owner_pid=os.read(int(sys.argv[3]),64).decode().strip()
|
|
os.dup2(s.fileno(),11,inheritable=True)
|
|
os.execve(int(sys.argv[2]),[sys.argv[4],'--exact','network::dns::settings::transport::tests::network_settings_transport_application_child','--nocapture'],{'ARCHY_TEST_ISOLATED':'1','ARCHY_NETWORK_OWNER_PID':owner_pid,'RUST_MIN_STACK':'16777216'})
|
|
s.settimeout(5)
|
|
s.send(json.dumps({'version':1,'sequence':1,'operation':{'Submit':{'Ethernet':{'interface':'eth0','ipv4':'Dhcp'}}}}).encode())
|
|
r=json.loads(s.recv(4096)); seq=1
|
|
while r['receipt']['phase'] not in ('Succeeded','Rejected','Unknown'):
|
|
time.sleep(.05); seq+=1
|
|
s.send(json.dumps({'version':1,'sequence':seq,'operation':{'Status':r['receipt']['ticket']}}).encode())
|
|
r=json.loads(s.recv(4096))
|
|
assert r['receipt']['phase']=='Succeeded',r
|
|
print('verified',flush=True)
|
|
time.sleep(1)
|
|
'''
|
|
executable = os.environ.get('ARCHY_NETWORK_TEST_EXE')
|
|
# The shared target has private ancestors. Retain the already-built ELF
|
|
# descriptor rather than weakening report-directory permissions for nobody.
|
|
executable_fd = os.open(executable, os.O_RDONLY | os.O_CLOEXEC) if executable else None
|
|
if executable_fd is not None:
|
|
original = executable_fd
|
|
executable_fd = fcntl.fcntl(original, fcntl.F_DUPFD_CLOEXEC, 32)
|
|
os.close(original)
|
|
arguments = [str(executable_fd), str(ready_read), executable] if executable else []
|
|
app = subprocess.Popen(['/usr/bin/python3', '-I', '-c', app_code, address.encode().hex(), *arguments], pass_fds=(ready_read, *([executable_fd] if executable else [])), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
if executable_fd is not None:
|
|
os.close(executable_fd)
|
|
os.close(ready_read)
|
|
control, child_control = socket.socketpair(socket.AF_UNIX, socket.SOCK_SEQPACKET)
|
|
for channel in (control, child_control):
|
|
channel.setsockopt(socket.SOL_SOCKET, socket.SO_PASSCRED, 1)
|
|
worker = None
|
|
accepted = None
|
|
bus_link = None
|
|
try:
|
|
accepted, _ = listener.accept()
|
|
accepted.setsockopt(socket.SOL_SOCKET, socket.SO_PASSCRED, 1)
|
|
self.assertEqual(struct.unpack('3i', accepted.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)), (app.pid, 65534, 65534))
|
|
# Inject only the disposable bus in this fixture interpreter. Production
|
|
# native embedding clears the environment and uses Gio's system bus.
|
|
code = '''import os,sys,fcntl
|
|
fds=[fcntl.fcntl(int(n),fcntl.F_DUPFD_CLOEXEC,32) for n in sys.argv[1:4]]
|
|
for target,fd in zip((3,4,5),fds): os.dup2(fd,target); os.close(fd)
|
|
for original in map(int,sys.argv[1:4]):
|
|
if original not in (3,4,5): os.close(original)
|
|
if len(sys.argv)>6:
|
|
os.execve(sys.argv[6],[sys.argv[6],'network-settings-owner-v1'],{})
|
|
sys.path.insert(0,sys.argv[4])
|
|
import gi
|
|
gi.require_version('Gio','2.0')
|
|
from gi.repository import Gio
|
|
import c01_network_settings_owner as owner
|
|
import c01_network_settings_nm as nm
|
|
original=nm.NetworkManager
|
|
address=sys.argv[5]
|
|
def factory():
|
|
bus=Gio.DBusConnection.new_for_address_sync(address,Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT|Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION,None,None)
|
|
return original(bus)
|
|
owner.Owner.__init__.__defaults__=(factory,)
|
|
owner.main()
|
|
'''
|
|
native = os.environ.get('ARCHY_NETWORK_NATIVE_EXE')
|
|
if native:
|
|
# /run is the isolated runner's private tmpfs, never the host bus.
|
|
os.makedirs('/run/dbus', mode=0o755, exist_ok=True)
|
|
bus_link = Path('/run/dbus/system_bus_socket')
|
|
self.assertFalse(bus_link.exists())
|
|
address = self.bus.test_bus.get_bus_address()
|
|
self.assertTrue(address.startswith('unix:path='))
|
|
bus_link.symlink_to(unquote(address[len('unix:path='):].split(',')[0]))
|
|
worker = subprocess.Popen(['/usr/bin/python3', '-I', '-c', code, str(child_control.fileno()), str(accepted.fileno()), str(self.fd), str(Path(__file__).resolve().parents[1]), self.bus.test_bus.get_bus_address(), *([native] if native else [])], pass_fds=(child_control.fileno(), accepted.fileno(), self.fd), stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
|
child_control.close()
|
|
observer = watch.Watch(control, accepted, worker.pid)
|
|
meta = os.fstat(self.fd)
|
|
owner.send(control, {'version': 1, 'process_id': worker.pid, 'application': {'pid': app.pid, 'uid': 65534, 'gid': 65534}, 'journal_device': meta.st_dev, 'journal_inode': meta.st_ino})
|
|
if executable:
|
|
os.write(ready_write, str(worker.pid).encode())
|
|
os.close(ready_write)
|
|
ready_write = None
|
|
end = time.monotonic() + 8
|
|
while time.monotonic() < end and app.poll() is None:
|
|
if not observer.receive():
|
|
# App exit revokes this dedicated role; that is expected only
|
|
# after the real typed client has completed successfully.
|
|
app.wait(timeout=1)
|
|
break
|
|
time.sleep(.02)
|
|
stdout, stderr = app.communicate(timeout=3)
|
|
self.assertEqual(app.returncode, 0, stderr.decode())
|
|
self.assertIn(b'verified', stdout)
|
|
self.assertEqual(self.bus.calls.count('Update2'), 1)
|
|
self.assertGreaterEqual(self.bus.state_reads, 3)
|
|
self.assertEqual(owner.Journal(self.fd).record['phase'], 'Succeeded')
|
|
if native:
|
|
print('NATIVE network-settings-owner-v1 -> disposable NM bus: verified', flush=True)
|
|
finally:
|
|
if ready_write is not None:
|
|
os.close(ready_write)
|
|
for process in (app, worker):
|
|
if process is not None:
|
|
if process.poll() is None:
|
|
process.kill()
|
|
process.communicate(timeout=3)
|
|
control.close()
|
|
child_control.close()
|
|
if accepted:
|
|
accepted.close()
|
|
listener.close()
|
|
if bus_link is not None and bus_link.is_symlink():
|
|
bus_link.unlink()
|
|
|
|
|
|
class ProtocolTests(unittest.TestCase):
|
|
def test_supervisor_role_budget_retirement_and_admission_refusal(self):
|
|
import c01_supervisor as supervisor
|
|
self.assertEqual(supervisor.lifecycle.ROLES['network-settings'], (128 * 1024**2, 16))
|
|
with self.assertRaises(supervisor.AdmissionUnavailable):
|
|
supervisor.admit({}, None, None)
|
|
class Children:
|
|
stopping = False
|
|
results = {}
|
|
def request_stop(self):
|
|
self.stopping = True
|
|
def poll(self):
|
|
return []
|
|
instance = object.__new__(supervisor.Supervisor)
|
|
instance.children = Children()
|
|
instance.failure = None
|
|
instance.watch = None
|
|
instance.tor_watch = None
|
|
instance.power_lease = None
|
|
instance.network_settings_watch = Mock()
|
|
instance.network_settings_watch.receive.return_value = False
|
|
instance.access_lease = instance.runtime_lease = instance.network_lease = None
|
|
instance.completed_one_shots = {'administrator'}
|
|
self.assertFalse(instance.tick())
|
|
instance.network_settings_watch.revoke.assert_called()
|
|
instance.network_settings_watch.release_reaped.assert_not_called()
|
|
instance.children.results = {'network-settings': 0}
|
|
self.assertFalse(instance.tick())
|
|
instance.network_settings_watch.release_reaped.assert_called_once()
|
|
|
|
def test_owner_validates_decoded_types_and_unknown_fields(self):
|
|
invalid = [
|
|
{'Ethernet': {'interface': 'eth0', 'ipv4': {'Static': {'address': 1, 'prefix': 24, 'gateway': None, 'dns': 'Preserve'}}}},
|
|
{'Wifi': {'ssid': 'a', 'password': '', 'argv': []}},
|
|
{'Dns': {'policy': {'Servers': ['::']}}},
|
|
{'Dns': {'policy': 'Preserve'}},
|
|
]
|
|
for mutation in invalid:
|
|
with self.assertRaises(ValueError):
|
|
nm.validate(mutation)
|
|
with self.assertRaises(ValueError):
|
|
owner.validate_ticket({'epoch': [True] * 16, 'sequence': 1})
|
|
|
|
def pair(self):
|
|
a, b = socket.socketpair(socket.AF_UNIX, socket.SOCK_SEQPACKET)
|
|
a.setsockopt(socket.SOL_SOCKET, socket.SO_PASSCRED, 1)
|
|
self.addCleanup(a.close)
|
|
self.addCleanup(b.close)
|
|
return a, b
|
|
|
|
def test_authenticated_packets_and_wrong_peer(self):
|
|
a, b = self.pair()
|
|
owner.send(b, {'ok': True})
|
|
self.assertEqual(owner.receive(a, (os.getpid(), os.getuid(), os.getgid())), {'ok': True})
|
|
owner.send(b, {'ok': True})
|
|
with self.assertRaises(ValueError):
|
|
owner.receive(a, (os.getpid() + 1, os.getuid(), os.getgid()))
|
|
|
|
def test_rights_truncation_duplicate_keys_refused(self):
|
|
a, b = self.pair()
|
|
b.sendmsg([b'{}'], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, array.array('i', [b.fileno()]))])
|
|
with self.assertRaises(ValueError):
|
|
owner.receive(a, (os.getpid(), os.getuid(), os.getgid()))
|
|
b.send(b'{"a":1,"a":2}')
|
|
with self.assertRaises(ValueError):
|
|
owner.receive(a, (os.getpid(), os.getuid(), os.getgid()))
|
|
b.send(b' ' * 4097)
|
|
with self.assertRaises(ValueError):
|
|
owner.receive(a, (os.getpid(), os.getuid(), os.getgid()))
|
|
|
|
def test_watch_heartbeat_never_extends_operation_deadline(self):
|
|
a, b = self.pair()
|
|
app, remote = self.pair()
|
|
with patch.object(watch, 'now', return_value=100):
|
|
observer = watch.Watch(a, app, os.getpid())
|
|
b.send(owner.PROGRESS.pack(1, 1, 0, 0, b'x' * 16))
|
|
self.assertTrue(observer.receive())
|
|
b.send(owner.PROGRESS.pack(1, 2, 1, 1000, b'x' * 16))
|
|
self.assertTrue(observer.receive())
|
|
b.send(owner.PROGRESS.pack(1, 3, 1, 1000, b'x' * 16))
|
|
self.assertTrue(observer.receive())
|
|
with patch.object(watch, 'now', return_value=1000):
|
|
self.assertFalse(observer.receive())
|
|
self.assertEqual(remote.recv(1), b'')
|
|
|
|
def test_watch_replay_and_peer_death(self):
|
|
a, b = self.pair()
|
|
app, remote = self.pair()
|
|
observer = watch.Watch(a, app, os.getpid())
|
|
raw = owner.PROGRESS.pack(1, 1, 0, 0, b'x' * 16)
|
|
b.send(raw)
|
|
self.assertTrue(observer.receive())
|
|
b.send(raw)
|
|
self.assertFalse(observer.receive())
|
|
self.assertEqual(remote.recv(1), b'')
|
|
|
|
def test_journal_symlink_refused(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
Path(directory, 'operation.json').symlink_to('/dev/null')
|
|
fd = os.open(directory, os.O_RDONLY | os.O_DIRECTORY)
|
|
try:
|
|
with self.assertRaises(OSError):
|
|
owner.Journal(fd)
|
|
finally:
|
|
os.close(fd)
|
|
|
|
def test_owner_deadline_single_slot_and_late_success_fence(self):
|
|
entered, release = threading.Event(), threading.Event()
|
|
class Delayed:
|
|
def execute(self, mutation, end, before, verifying):
|
|
before(['fixture-profile'])
|
|
entered.set()
|
|
if not release.wait(3):
|
|
raise ValueError('fixture release timeout')
|
|
verifying()
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
fd = os.open(directory, os.O_RDONLY | os.O_DIRECTORY)
|
|
instance = owner.Owner(owner.Journal(fd), Delayed)
|
|
try:
|
|
first = instance.submit({'Dns': {'policy': 'Automatic'}})
|
|
self.assertTrue(entered.wait(2))
|
|
with self.assertRaises(ValueError):
|
|
instance.submit({'Dns': {'policy': 'Automatic'}})
|
|
with patch.object(owner, 'now', return_value=instance.end):
|
|
self.assertEqual(instance.status(first['ticket'])['phase'], 'Unknown')
|
|
release.set()
|
|
instance.thread.join(3)
|
|
self.assertEqual(instance.status(first['ticket'])['phase'], 'Unknown')
|
|
self.assertTrue(owner.Journal(fd).blocked())
|
|
finally:
|
|
release.set()
|
|
instance.thread.join(3)
|
|
os.close(fd)
|
|
|
|
def test_idle_watch_stall_and_control_peer_death(self):
|
|
a, b = self.pair()
|
|
app, remote = self.pair()
|
|
with patch.object(watch, 'now', return_value=100):
|
|
observer = watch.Watch(a, app, os.getpid())
|
|
b.send(owner.PROGRESS.pack(1, 1, 0, 0, b'x' * 16))
|
|
self.assertTrue(observer.receive())
|
|
with patch.object(watch, 'now', return_value=2_000_000_100):
|
|
self.assertFalse(observer.receive())
|
|
self.assertEqual(remote.recv(1), b'')
|
|
a, b = self.pair()
|
|
app, remote = self.pair()
|
|
observer = watch.Watch(a, app, os.getpid())
|
|
b.close()
|
|
self.assertFalse(observer.receive())
|
|
self.assertEqual(remote.recv(1), b'')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|