Publish Vulpes Preview 15 sources for desktop, Android and Tundra

Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
This commit is contained in:
OverSu 2026-09-30 23:13:03 +02:00
commit b38dea0597
25901 changed files with 9982925 additions and 369 deletions

156
tundra/runtime/buttons.py Normal file
View file

@ -0,0 +1,156 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Sargo key events and fixed backlight/volume controls; no general command API."""
import json
import os
from pathlib import Path
import selectors
import socket
import struct
import subprocess
import sys
import time
import uuid
SOCKET = Path('/run/tundra-buttons/control.sock')
EVENTS = SOCKET.with_name('events.json')
BACKLIGHT = Path('/sys/class/backlight/panel0-backlight')
KEYS = {116: 'sleep', 115: 'volume-up', 114: 'volume-down'}
INPUT = struct.Struct('@llHHi')
def key_event(kind, code, value):
if kind != 1 or code not in KEYS or value not in (0, 1):
return None
return KEYS[code] + '-button-' + ('press' if value else 'release')
VIBRATOR = Path('/sys/class/leds/vibrator')
# This kernel routes DRV2624 start/stop through LED brightness. The legacy
# activate/duration attributes do not reliably stop the physical motor.
class Vibration:
def __init__(self):
self.steps = []
self.deadline = None
def start(self, pattern):
if (not isinstance(pattern, list) or len(pattern) > 32 or
any(type(n) is not int or not 0 <= n <= 10000 for n in pattern) or
sum(pattern) > 10000):
raise ValueError('INVALID_VIBRATION')
self.steps = list(enumerate(pattern))
self.deadline = None
(VIBRATOR/'brightness').write_text('0')
self.advance(time.monotonic())
def advance(self, now):
if self.deadline is not None and now < self.deadline:
return
self.deadline = None
while self.steps:
index, duration = self.steps.pop(0)
if not duration:
continue
if index % 2 == 0:
(VIBRATOR/'brightness').write_text('255')
else:
(VIBRATOR/'brightness').write_text('0')
self.deadline = now + duration / 1000
return
(VIBRATOR/'brightness').write_text('0')
vibration = Vibration()
def control(p):
if p.get('operation') == 'screen':
enabled, level = p.get('enabled'), p.get('level')
if type(enabled) is not bool or type(level) not in (int, float) or not 0 <= level <= 1:
raise ValueError('INVALID_SCREEN')
maximum = int((BACKLIGHT/'max_brightness').read_text())
(BACKLIGHT/'brightness').write_text(str(max(1, round(maximum * level)) if enabled else 0))
elif p.get('operation') == 'vibrate':
vibration.start(p.get('pattern'))
elif p.get('operation') == 'volume':
level = p.get('level')
if type(level) not in (int, float) or not 0 <= level <= 1:
raise ValueError('INVALID_VOLUME')
env = {**os.environ, 'XDG_RUNTIME_DIR': '/run/user/32011',
'PULSE_SERVER': 'unix:/run/user/32011/pulse/native'}
subprocess.run(['/usr/bin/pactl',
'set-sink-volume', '@DEFAULT_SINK@', str(round(level*100))+'%'],
env=env, user=32011, group=32011, extra_groups=[], check=True, timeout=3, capture_output=True)
elif p.get('operation') == 'power' and p.get('action') in ('reboot', 'poweroff'):
subprocess.run(['/usr/bin/systemctl', '--no-block', p['action']], check=True, timeout=3, capture_output=True)
else:
raise ValueError('NOT_SUPPORTED')
return True
def serve():
SOCKET.parent.mkdir(parents=True, exist_ok=True)
SOCKET.unlink(missing_ok=True)
state = {'session': str(uuid.uuid4()), 'sequence': 0, 'events': []}
def publish():
state['recordedAt'] = time.time()
temporary = EVENTS.with_suffix('.tmp')
temporary.write_text(json.dumps(state))
temporary.chmod(0o644)
temporary.replace(EVENTS)
publish()
with selectors.DefaultSelector() as selector, socket.socket(socket.AF_UNIX) as server:
server.bind(str(SOCKET)); SOCKET.chmod(0o660)
import grp
os.chown(SOCKET, 0, grp.getgrnam('droidian').gr_gid)
server.listen(8); selector.register(server, selectors.EVENT_READ)
for device in Path('/sys/class/input').glob('event*'):
if (device/'device/name').read_text().strip() not in ('qpnp_pon', 'gpio-keys'):
continue
fd = os.open('/dev/input/'+device.name, os.O_RDONLY | os.O_NONBLOCK)
selector.register(fd, selectors.EVENT_READ)
while True:
timeout = max(0, min(1, vibration.deadline - time.monotonic())) if vibration.deadline is not None else 1
for key, _ in selector.select(timeout):
if key.fileobj is server:
conn, _ = server.accept()
with conn:
conn.settimeout(2)
try:
raw = conn.makefile('rb').readline(1024)
result = control(json.loads(raw))
reply = {'ok': True, 'result': result}
except Exception:
reply = {'ok': False, 'error': 'HARDWARE_REQUEST_FAILED'}
conn.sendall((json.dumps(reply)+'\n').encode())
else:
data = os.read(key.fd, INPUT.size*64)
for offset in range(0, len(data), INPUT.size):
_, _, kind, code, value = INPUT.unpack_from(data, offset)
event = key_event(kind, code, value)
if event:
# Always allow a physical press to recover a black display,
# even if Gaia has stopped responding.
if code == 116 and value == 1 and int((BACKLIGHT/'brightness').read_text()) == 0:
control({'operation':'screen', 'enabled':True, 'level':0.5})
state['sequence'] += 1
state['events'].append({'id':state['sequence'], 'type':event})
state['events'] = state['events'][-64:]
if vibration.deadline is not None:
vibration.advance(time.monotonic())
publish()
def client():
raw = sys.stdin.read(1024)
with socket.socket(socket.AF_UNIX) as sock:
sock.settimeout(5); sock.connect(str(SOCKET))
sock.sendall(raw.encode()+b'\n')
print(sock.makefile('r').readline(2048).strip())
if __name__ == '__main__':
if '--stop-vibration' in sys.argv:
(VIBRATOR/'brightness').write_text('0')
elif '--client' in sys.argv:
client()
else:
try:
(VIBRATOR/'brightness').write_text('0')
serve()
finally:
(VIBRATOR/'brightness').write_text('0')

View file

@ -0,0 +1,216 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Route modem calls through the Droid audio HAL; never dial or record audio.
Profile/parking sequence: mer-hybris/pulseaudio-modules-droid README.
Only the physical earpiece is selected automatically, never the loudspeaker.
"""
import importlib.util
import json
import os
import re
import socket
import struct
import sys
from pathlib import Path
import subprocess
import time
def names(items):
return set(items) if isinstance(items, dict) else {p['name'] for p in items}
def parse_devices(text):
"""Read pactl's C-locale listing, including PulseAudio versions before JSON."""
devices, current, section = [], None, None
scalar = {'Name':'name', 'Owner Module':'owner_module', 'Active Profile':'active_profile',
'Active Port':'active_port', 'Mute':'mute'}
for line in text.splitlines():
header = re.fullmatch(r'(?:Card|Sink|Source|Sink Input) #(\d+)', line)
if header:
current = {'index':int(header[1]), 'ports':{}, 'profiles':{}, 'properties':{}}
devices.append(current); section = None
elif current is not None and line.startswith('\t') and not line.startswith('\t\t'):
key, separator, value = line.strip().partition(':')
section = key.lower() if key in ('Ports', 'Profiles', 'Properties') else None
if key in scalar and separator:
value = value.strip()
if key == 'Owner Module': value = int(value)
elif key == 'Mute': value = value == 'yes'
current[scalar[key]] = value
elif current is not None and line.startswith('\t\t') and not line.startswith('\t\t\t'):
value = line.strip()
if section in ('ports', 'profiles') and ':' in value:
current[section][value.split(':', 1)[0]] = {}
elif section == 'properties' and ' = ' in value:
key, value = value.split(' = ', 1)
current['properties'][key] = value.strip('"')
return devices
def pactl(*args):
result = subprocess.run(
['/usr/bin/pactl', *args], capture_output=True, text=True, timeout=5,
user=32011, group=32011, extra_groups=[],
env={**os.environ, 'XDG_RUNTIME_DIR':'/run/user/32011',
'PULSE_SERVER':'unix:/run/user/32011/pulse/native', 'LC_ALL':'C'})
if result.returncode:
raise RuntimeError('CALL_AUDIO_COMMAND_FAILED')
return result.stdout
class CallAudio:
def __init__(self, command=pactl):
self.command = command
self.saved = None
self.active = False
self.speaker = False
self.muted = False
self.volume = 0.6
def devices(self, kind):
return parse_devices(self.command('list', kind))
def enter(self):
if self.active:
return
if self.saved is not None:
self.leave()
card = next((c for c in self.devices('cards')
if c['name'] == 'droid_card.primary' and 'voicecall' in names(c['profiles'])), None)
if card is None:
raise RuntimeError('CALL_AUDIO_CARD_UNAVAILABLE')
sinks = self.devices('sinks')
sink = next((s for s in sinks if s.get('owner_module') == card['owner_module'] and
'output-earpiece' in names(s['ports']) and 'output-parking' in names(s['ports'])), None)
source = next((s for s in self.devices('sources') if s['name'] == 'source.droid'), None)
if sink is None or source is None:
raise RuntimeError('CALL_AUDIO_EARPIECE_UNAVAILABLE')
self.saved = {'card':card['name'], 'profile':card['active_profile'],
'sink':sink['name'], 'port':sink['active_port'], 'sinkMute':sink['mute'],
'source':source['name'], 'sourceMute':source['mute']}
try:
self.command('set-card-profile', card['name'], 'voicecall')
self.command('set-sink-port', sink['name'], 'output-parking')
self.command('set-sink-port', sink['name'], 'output-earpiece')
self.command('set-sink-mute', sink['name'], '0')
self.command('set-source-mute', source['name'], '0')
# voice_virtual_stream creates this HAL volume-control stream.
for stream in self.devices('sink-inputs'):
if stream.get('properties', {}).get('media.role') == 'phone':
self.command('set-sink-input-volume', str(stream['index']), str(round(self.volume * 100)) + '%')
self.active = True
except Exception:
self.leave()
raise
def control(self, request):
if not isinstance(request, dict) or set(request) - {'operation', 'speaker', 'muted', 'volume'}:
raise ValueError('INVALID_AUDIO_REQUEST')
if request.get('operation') == 'status':
return {'active': self.active, 'speaker': self.speaker, 'muted': self.muted, 'volume': self.volume}
if request.get('operation') != 'set' or not self.active:
raise ValueError('CALL_AUDIO_NOT_ACTIVE')
for key in ('speaker', 'muted'):
if key in request and type(request[key]) is not bool:
raise ValueError('INVALID_AUDIO_REQUEST')
if 'volume' in request and (type(request['volume']) not in (int, float) or not 0 <= request['volume'] <= 1):
raise ValueError('INVALID_AUDIO_REQUEST')
if 'speaker' in request:
port = 'output-speaker' if request['speaker'] else 'output-earpiece'
sink = next((s for s in self.devices('sinks') if s['name'] == self.saved['sink']), None)
if not sink or port not in names(sink['ports']):
raise RuntimeError('CALL_AUDIO_PORT_UNAVAILABLE')
self.command('set-sink-port', sink['name'], 'output-parking')
self.command('set-sink-port', sink['name'], port)
self.speaker = request['speaker']
if 'muted' in request:
self.command('set-source-mute', self.saved['source'], str(int(request['muted'])))
self.muted = request['muted']
if 'volume' in request:
streams = [s for s in self.devices('sink-inputs') if s.get('properties', {}).get('media.role') == 'phone']
if not streams:
raise RuntimeError('CALL_AUDIO_VOLUME_UNAVAILABLE')
for stream in streams:
self.command('set-sink-input-volume', str(stream['index']), str(round(request['volume'] * 100)) + '%')
self.volume = request['volume']
return self.control({'operation': 'status'})
def leave(self):
if self.saved is None:
return
self.active = False
state = self.saved
self.command('set-card-profile', state['card'], state['profile'])
self.command('set-sink-port', state['sink'], 'output-parking')
self.command('set-sink-port', state['sink'], state['port'])
self.command('set-sink-mute', state['sink'], str(int(state['sinkMute'])))
self.command('set-source-mute', state['source'], str(int(state['sourceMute'])))
self.saved = None
self.speaker = False
self.muted = False
def update(self, calls):
if any(c['state'] in ('dialing', 'alerting', 'active', 'held') for c in calls):
self.enter()
else:
self.leave()
def main():
spec = importlib.util.spec_from_file_location('radio', Path(__file__).with_name('radio-control.py'))
radio = importlib.util.module_from_spec(spec); spec.loader.exec_module(radio)
route = CallAudio()
target = Path('/run/tundra-call-audio/status.json')
path = target.with_name('control.sock')
path.unlink(missing_ok=True)
with socket.socket(socket.AF_UNIX) as server:
server.bind(str(path))
os.chown(path, 0, 32011)
path.chmod(0o660)
server.listen(4)
server.settimeout(0.3)
next_poll = 0
while True:
if time.monotonic() >= next_poll:
try:
route.update(radio.request({'version':1, 'operation':'calls'}))
state = {**route.control({'operation':'status'}), 'error':None}
except Exception as error:
state = {'active':route.active, 'error':str(error)}
state.update(routed=route.active, recordedAt=time.time())
tmp = target.with_suffix('.tmp'); tmp.write_text(json.dumps(state)); tmp.replace(target)
next_poll = time.monotonic() + 1
try:
conn, _ = server.accept()
except socket.timeout:
continue
with conn:
conn.settimeout(2)
try:
_, uid, _ = struct.unpack('3i', conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
if uid not in (0, 32011):
raise ValueError('PERMISSION_DENIED')
line = conn.makefile('rb').readline(1024)
if not line.endswith(b'\n'):
raise ValueError('INVALID_AUDIO_REQUEST')
result = {'result':route.control(json.loads(line))}
except Exception as error:
result = {'error':str(error)}
try:
conn.sendall((json.dumps(result)+'\n').encode())
except OSError:
pass
def client():
with socket.socket(socket.AF_UNIX) as sock:
sock.settimeout(8)
sock.connect('/run/tundra-call-audio/control.sock')
sock.sendall(sys.stdin.buffer.read(1024).rstrip(b'\n') + b'\n')
print(sock.makefile('r').readline(4096))
if __name__ == '__main__':
client() if '--client' in sys.argv else main()

View file

@ -0,0 +1,269 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Bounded libhybris preview worker. Frames stay in memory; no microphone access.
The parent owns stdin. EOF or five seconds without a request closes the sensor.
A pbuffer and external texture are required even for the software NV21 callback.
"""
import argparse
import struct
import ctypes as C
import json
import os
import select
import sys
import time
def function(lib, name, args, result=None):
value = getattr(lib, name)
value.argtypes, value.restype = args, result
return value
class Listener(C.Structure):
_fields_ = [(name, C.c_void_p) for name in
('error', 'shutter', 'focus', 'zoom', 'raw', 'jpeg', 'texture', 'context', 'preview')]
class Camera:
width, height = 640, 480
def __init__(self, index):
self.camera = None
self.display = self.surface = self.context = None
self.frame = None
self.jpeg = None
self.focus_done = False
self.count = self.errors = 0
self.texture_pending = False
os.environ['HYBRIS_EGLPLATFORM'] = 'null'
os.environ['__EGL_VENDOR_LIBRARY_FILENAMES'] = '/usr/share/glvnd/egl_vendor.d/10_libhybris.json'
self.egl = C.CDLL('libEGL.so.1')
self.gl = C.CDLL('libGLESv2.so.2')
self.lib = C.CDLL('libcamera.so.1')
P, I, U = C.c_void_p, C.c_int, C.c_uint
self.display = function(self.egl, 'eglGetDisplay', [P], P)(None)
if not function(self.egl, 'eglInitialize', [P, P, P], U)(self.display, None, None):
raise RuntimeError('CAMERA_EGL_INIT_FAILED')
attrs = (I * 13)(0x3033, 1, 0x3040, 4, 0x3024, 8, 0x3023, 8, 0x3022, 8, 0x3021, 8, 0x3038)
config, count = P(), I()
function(self.egl, 'eglChooseConfig', [P, P, P, I, P], U)(self.display, attrs, C.byref(config), 1, C.byref(count))
if not count.value:
raise RuntimeError('CAMERA_EGL_CONFIG_FAILED')
function(self.egl, 'eglBindAPI', [U], U)(0x30A0)
self.context = function(self.egl, 'eglCreateContext', [P, P, P, P], P)(self.display, config, None, (I * 3)(0x3098, 2, 0x3038))
self.surface = function(self.egl, 'eglCreatePbufferSurface', [P, P, P], P)(self.display, config, (I * 5)(0x3057, 16, 0x3056, 16, 0x3038))
if not function(self.egl, 'eglMakeCurrent', [P, P, P, P], U)(self.display, self.surface, self.surface, self.context):
raise RuntimeError('CAMERA_EGL_CONTEXT_FAILED')
texture = U()
function(self.gl, 'glGenTextures', [I, P])(1, C.byref(texture))
@C.CFUNCTYPE(None, P, U, P)
def preview(data, size, _context):
if size == self.width * self.height * 3 // 2:
self.frame = C.string_at(data, size)
self.count += 1
@C.CFUNCTYPE(None, P)
def error(_context):
self.errors += 1
@C.CFUNCTYPE(None, P)
def available(_context):
self.texture_pending = True
@C.CFUNCTYPE(None, P)
def focused(_context):
self.focus_done = True
@C.CFUNCTYPE(None, P, U, P)
def jpeg(data, size, _context):
if 0 < size <= 32 * 1024 * 1024:
self.jpeg = C.string_at(data, size)
self.callbacks = (preview, error, available, focused, jpeg)
self.listener = Listener()
for name, callback in [('preview', preview), ('error', error), ('texture', available), ('focus', focused), ('jpeg', jpeg)]:
setattr(self.listener, name, C.cast(callback, P).value)
self.camera = function(self.lib, 'android_camera_connect_by_id', [I, P], P)(index, C.byref(self.listener))
if not self.camera:
raise RuntimeError('CAMERA_CONNECT_FAILED')
facing, orientation = I(), I()
function(self.lib, 'android_camera_get_device_info', [I, P, P], I)(index, C.byref(facing), C.byref(orientation))
self.orientation = orientation.value
function(self.lib, 'android_camera_set_preview_size', [P, I, I])(self.camera, self.width, self.height)
function(self.lib, 'android_camera_set_preview_format', [P, I])(self.camera, 1) # YUV420SP / NV21
function(self.lib, 'android_camera_set_preview_texture', [P, I])(self.camera, texture.value)
function(self.lib, 'android_camera_set_preview_callback_mode', [P, I], I)(self.camera, 1)
function(self.lib, 'android_camera_start_preview', [P])(self.camera)
self.parameters = self.read_parameters()
self.picture_sizes = [tuple(map(int, size.split('x'))) for size in self.parameters.get('picture-size-values', '').split(',') if 'x' in size]
self.picture_size = max(self.picture_sizes, key=lambda size:size[0]*size[1])
self.zoom_ratios = [int(n)/100 for n in self.parameters.get('zoom-ratios', '100').split(',')]
def read_parameters(self):
# libhybris exposes the flattened parameters through a stdout dump.
# Capture it in RAM so it cannot corrupt the worker's binary protocol.
libc = C.CDLL(None)
fd = os.memfd_create('camera-parameters')
saved = os.dup(1)
try:
libc.fflush(None)
os.dup2(fd, 1)
function(self.lib, 'android_camera_dump_parameters', [C.c_void_p])(self.camera)
libc.fflush(None)
finally:
os.dup2(saved, 1)
os.close(saved)
try:
os.lseek(fd, 0, 0)
raw = os.read(fd, 65536).decode()
return dict(item.split('=', 1) for item in raw.strip().split(';') if '=' in item)
finally:
os.close(fd)
def metadata(self):
return {'ready': True, 'width': self.width, 'height': self.height,
'orientation': self.orientation, 'format': 'nv21',
'pictureSizes': [{'width':w,'height':h} for w,h in self.picture_sizes],
'focusModes': self.parameters.get('focus-mode-values', 'fixed').split(','),
'maxFocusAreas': min(1,int(self.parameters.get('max-num-focus-areas', 0))),
'zoomRatios': self.zoom_ratios}
def control(self, command):
P, I = C.c_void_p, C.c_int
action = command.get('action')
if action == 'zoom':
ratio = command.get('zoom')
if not isinstance(ratio, (int,float)) or not self.zoom_ratios[0] <= ratio <= self.zoom_ratios[-1]:
raise ValueError('CAMERA_INVALID_ZOOM')
index = min(range(len(self.zoom_ratios)), key=lambda i:abs(self.zoom_ratios[i]-ratio))
function(self.lib,'android_camera_set_zoom',[P,I])(self.camera,index)
actual = int(self.read_parameters().get('zoom',index))
if not 0 <= actual < len(self.zoom_ratios): raise RuntimeError('CAMERA_INVALID_ZOOM_REPLY')
return {'zoom':self.zoom_ratios[actual]}
if action == 'focus':
if 'auto' not in self.metadata()['focusModes']:
raise ValueError('CAMERA_FOCUS_UNAVAILABLE')
function(self.lib,'android_camera_stop_autofocus',[P])(self.camera)
function(self.lib,'android_camera_set_auto_focus_mode',[P,I])(self.camera,2)
area = command.get('area')
if area is not None:
values = [area.get(k) for k in ('top','left','bottom','right')]
if any(not isinstance(n,int) or not -1000 <= n <= 1000 for n in values) or values[0]>=values[2] or values[1]>=values[3]:
raise ValueError('CAMERA_INVALID_FOCUS_AREA')
if not self.metadata()['maxFocusAreas']:
raise ValueError('CAMERA_FOCUS_AREA_UNAVAILABLE')
region = (I*5)(*values,1000)
function(self.lib,'android_camera_set_focus_region',[P,P])(self.camera,region)
else:
function(self.lib,'android_camera_reset_focus_region',[P])(self.camera)
self.focus_done = False
function(self.lib,'android_camera_start_autofocus',[P])(self.camera)
deadline = time.monotonic()+4
while not self.focus_done and time.monotonic()<deadline:
self.tick(); time.sleep(.01)
if not self.focus_done:
raise RuntimeError('CAMERA_FOCUS_TIMEOUT')
return {'completed':True}
if action == 'picture':
size = command.get('size') or dict(zip(('width','height'),self.picture_size))
pair = (size.get('width'),size.get('height'))
if pair not in self.picture_sizes:
raise ValueError('CAMERA_INVALID_PICTURE_SIZE')
function(self.lib,'android_camera_set_picture_size',[P,I,I])(self.camera,*pair)
function(self.lib,'android_camera_set_jpeg_quality',[P,I])(self.camera,95)
function(self.lib,'android_camera_set_rotation',[P,I])(self.camera,self.orientation)
self.jpeg = None
try:
function(self.lib,'android_camera_take_snapshot',[P])(self.camera)
deadline = time.monotonic()+12
while self.jpeg is None and time.monotonic()<deadline:
self.tick(); time.sleep(.01)
if self.jpeg is None:
raise RuntimeError('CAMERA_CAPTURE_TIMEOUT')
return self.jpeg
finally:
function(self.lib,'android_camera_start_preview',[P])(self.camera)
raise ValueError('CAMERA_INVALID_COMMAND')
def tick(self):
if self.texture_pending:
self.texture_pending = False
function(self.lib, 'android_camera_update_preview_texture', [C.c_void_p])(self.camera)
def close(self):
P = C.c_void_p
if self.camera:
function(self.lib, 'android_camera_stop_preview', [P])(self.camera)
function(self.lib, 'android_camera_disconnect', [P])(self.camera)
self.camera = None
if self.display:
function(self.egl, 'eglMakeCurrent', [P, P, P, P], C.c_uint)(self.display, None, None, None)
if self.surface:
function(self.egl, 'eglDestroySurface', [P, P], C.c_uint)(self.display, self.surface)
if self.context:
function(self.egl, 'eglDestroyContext', [P, P], C.c_uint)(self.display, self.context)
function(self.egl, 'eglTerminate', [P], C.c_uint)(self.display)
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--camera', type=int, choices=(0, 1), default=0)
parser.add_argument('--probe', action='store_true', help='Count frames for five seconds, retain no images')
args = parser.parse_args()
output = os.fdopen(os.dup(1), 'wb', buffering=0)
os.dup2(2, 1) # HAL callbacks can print; keep stdout protocol uncontaminated.
camera = None
try:
camera = Camera(args.camera)
started = last_request = time.monotonic()
if not args.probe:
metadata = json.dumps(camera.metadata()).encode()
output.write(struct.pack('<I', len(metadata)) + metadata)
output.flush()
while time.monotonic() - last_request < 5:
camera.tick()
if args.probe:
time.sleep(.01)
continue
if not select.select([sys.stdin], [], [], .01)[0]:
continue
command = sys.stdin.readline(4096)
if not command or len(command)>=4096:
break
last_request = time.monotonic()
if command == 'frame\n':
data = camera.frame or b''
output.write(struct.pack('<II', camera.count & 0xffffffff, len(data)))
output.write(data)
else:
try:
result = camera.control(json.loads(command))
if isinstance(result,bytes):
data = json.dumps({'ok':True,'jpegLength':len(result)}).encode()
else:
data = json.dumps({'ok':True,'result':result}).encode()
except (ValueError,RuntimeError,TypeError,AttributeError) as error:
result = None
data = json.dumps({'ok':False,'error':str(error)}).encode()
output.write(struct.pack('<I',len(data))+data)
if isinstance(result,bytes):
output.write(result)
output.flush()
last_request = time.monotonic()
if args.probe:
output.write((json.dumps({'camera': args.camera, 'frames': camera.count, 'errors': camera.errors,
'width': camera.width, 'height': camera.height, 'orientation': camera.orientation,
'seconds': round(time.monotonic() - started, 2), 'imagesRetained': False})+'\n').encode())
if not camera.count or camera.errors:
return 1
return 0
finally:
if camera:
camera.close()
if __name__ == '__main__':
sys.exit(main())

View file

@ -0,0 +1,98 @@
# SPDX-License-Identifier: MPL-2.0
"""Reduce Android init to display HAL services for the read-only Sargo trial.
Input files retain their upstream copyright notices. Removed commands and services
are reported; this is not a general Android init parser or a production policy.
"""
import shlex
SERVICES = frozenset(('ueventd','logd','servicemanager','hwservicemanager','vndservicemanager',
'vendor.hwcomposer-2-2','vendor.qti.hardware.display.allocator','vendor.configstore-hal',
'vendor.power-hal-1-3','vendor.thermal-engine','thermal-hal-1-1','hidl_memory'))
COMMANDS = frozenset(('mkdir','chmod','chown','write','setprop','symlink','copy',
'restorecon','restorecon_recursive','trigger','class_start','class_stop',
'load_system_props','load_persist_props','export','setrlimit','hostname','domainname','loglevel','sysclktz','start','stop','restart'))
PSEUDO_FS = frozenset(('cgroup','cgroup2','cpuset','tmpfs','proc','sysfs','debugfs','pstore','bpf'))
# The native network trial supplies rmt_storage with RAM copies of NV files.
# It retains the blanket denial of block-device access for this container.
NETWORK_SERVICES = frozenset(('irsc_util', 'vendor.per_mgr', 'vendor.rmt_storage',
'vendor.tftp_server', 'cnss-daemon', 'pd_mapper'))
HARDWARE_SERVICES = frozenset(('vendor.qcrild', 'vendor.google.radioext@1.0',
'vendor.audio-hal-2-0', 'vendor.camera-provider-2-4',
'camera_service', 'minimedia', 'minisf', 'miniaf'))
def is_init_script(path):
# ueventd uses a different grammar: these files define device permissions.
return not path.name.startswith('ueventd')
def command_allowed(tokens):
if not tokens:
return True
op=tokens[0]
if op=='onrestart':
return command_allowed(tokens[1:])
if op in ('start','stop','restart'):
return len(tokens)==2 and tokens[1] in SERVICES
if op=='mount':
return len(tokens)>3 and tokens[1] in PSEUDO_FS and not tokens[2].startswith('/dev/')
if op not in COMMANDS:
return False
if any('/dev/block' in t or '/dev/mmcblk' in t or '/sys/kernel/config' in t or '/sys/class/android_usb' in t or '/config/usb_gadget' in t or '/proc/sys/kernel/panic' in t or '/proc/sysrq-trigger' in t for t in tokens[1:]):
return False
if op=='setprop' and len(tokens)>2 and tokens[1].startswith('ctl.'):
return tokens[1] in ('ctl.start','ctl.stop','ctl.restart') and tokens[2] in SERVICES
return True
def logical_lines(text):
pending = ''
first = 1
for number, line in enumerate(text.splitlines(), 1):
if not pending: first = number
pending += line
if pending.endswith('\\'):
pending = pending[:-1]+' '
continue
try:
shlex.split(pending.strip(), comments=True)
except ValueError:
pending += '\n'
continue
yield first, pending
pending = ''
if pending:
raise ValueError('Unterminated Android init command at line '+str(first))
def filter_rc(text):
output=[];removed=[];kept=[];section=None;keep=True
for number,line in logical_lines(text):
stripped=line.strip()
if not stripped or stripped.startswith('#'):
if keep:output.append(line)
continue
tokens=shlex.split(stripped,comments=True)
if not line[0].isspace():
section=tokens[0];keep=True
if section=='service':
keep=len(tokens)>2 and tokens[1] in SERVICES and not any('_DISABLED' in t for t in tokens[2:])
if keep:kept.append(tokens[1])
elif section=='import':
# USB, recovery, and Android app processes are outside this trial.
keep=not any(word in stripped for word in ('usb','recovery','zygote'))
elif section!='on':
keep=False
if keep:output.append(line)
else:removed.append({'line':number,'text':line})
continue
accepted=keep and (section=='service' or command_allowed(tokens))
# Keep failed trial services inspectable instead of rebooting the device.
if section=='service' and tokens[0]=='critical':accepted=False
if section=='service' and tokens[0]=='onrestart':accepted=keep and command_allowed(tokens)
if accepted:output.append(line)
else:removed.append({'line':number,'text':line})
return '\n'.join(output)+'\n',{'services':kept,'removed':removed}

View file

@ -0,0 +1,64 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Publish read-only hardware state. No SSIDs, IP addresses or modem identifiers."""
import argparse
import json
import os
from pathlib import Path
import subprocess
import time
def battery_state(root=Path('/sys/class/power_supply/battery')):
try:
level = int((root/'capacity').read_text().strip())
status = (root/'status').read_text().strip()
if not 0 <= level <= 100 or status not in ('Charging', 'Discharging', 'Full', 'Not charging'):
return None
return {'level': level/100, 'charging': status in ('Charging', 'Full'), 'status': status}
except (OSError, ValueError):
return None
def wifi_state():
try:
result = subprocess.run(['nmcli', '-g', 'GENERAL.STATE', 'device', 'show', 'wlan0'],
capture_output=True, text=True, timeout=3,
env={**os.environ, 'LC_ALL': 'C'})
if result.returncode:
return {'available': False, 'state': 'unavailable'}
state = int(result.stdout.split()[0])
names = {10:'unmanaged', 20:'unavailable', 30:'disconnected', 40:'connecting',
50:'connecting', 60:'authentication', 70:'connecting', 80:'connecting',
90:'connecting', 100:'connected', 110:'disconnecting', 120:'failed'}
return {'available': state >= 30, 'state': names.get(state, 'unknown')}
except (OSError, ValueError, IndexError, subprocess.TimeoutExpired):
return {'available': False, 'state': 'unavailable'}
def snapshot():
# Do not D-Bus-activate NetworkManager from an otherwise display-only boot.
wifi = wifi_state() if Path('/etc/tundra/network-enabled').is_file() else {
'available': False, 'state': 'unavailable'}
return {'schema': 1, 'recordedAt': time.time(), 'battery': battery_state(), 'wifi': wifi}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--once', action='store_true')
args = parser.parse_args()
if args.once:
print(json.dumps(snapshot()))
return
output = Path('/run/tundra-hardware/status.json')
output.parent.mkdir(mode=0o755, exist_ok=True)
while True:
temporary = output.with_suffix('.tmp')
temporary.write_text(json.dumps(snapshot())+'\n')
temporary.chmod(0o644)
temporary.replace(output)
time.sleep(5)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,39 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Restrict the legacy HAL to character devices before creating its container.
Sargo's 4.9 kernel rejects LXC's cgroup2 device BPF program. A separate v1
devices controller is required; LXC's usual rules alone do not enforce it.
"""
import errno
import os
from pathlib import Path
import subprocess
import sys
def main():
if 'tundra.rescue=1' not in Path('/proc/cmdline').read_text():
raise RuntimeError('Temporary boot flag missing')
controller=Path('/run/tundra-device-controller')
controller.mkdir(exist_ok=True)
if not (controller/'devices.deny').exists():
subprocess.run(['mount','-t','cgroup','-o','devices','devices',str(controller)],check=True)
group=controller/'hal'
group.mkdir(exist_ok=True)
(group/'devices.deny').write_text('b *:* rwm')
(group/'cgroup.procs').write_text(str(os.getpid()))
# Test the actual open, not devices.list: this kernel lists a default allow
# rule even when the blacklist is active. No bytes are written or read.
try:
descriptor=os.open('/dev/mmcblk0',os.O_RDONLY)
except OSError as error:
if error.errno!=errno.EPERM:raise
else:
os.close(descriptor)
raise RuntimeError('Block device restriction was not enforced')
with open('/dev/null','wb'):pass
os.execv('/usr/bin/lxc-start',['lxc-start']+sys.argv[1:])
if __name__=='__main__':main()

View file

@ -0,0 +1,101 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Configure isolated oFono/PulseAudio diagnostics; no call or SMS on startup."""
from pathlib import Path
def prepare(root, write):
write('etc/tundra/hardware-enabled', 'sargo\n')
with (root/'etc/group').open('a') as groups:
groups.write('audio:x:1005:droidian\ncamera:x:1006:droidian\nradio:x:1001:\nmedia:x:1013:droidian\n')
write('etc/ofono/binder.conf', '[Settings]\nExpectSlots = slot1\n\n[slot1]\npath = /ril_0\nslot = 0\n')
# Only the diagnostic root process may operate the modem. Gaia needs its
# separate, permission-checked broker before telephony can be exposed.
write('etc/dbus-1/system.d/ofono.conf', '''<!DOCTYPE busconfig PUBLIC
"-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
<busconfig>
<policy context="default"><deny send_destination="org.ofono"/></policy>
<policy user="root"><allow own="org.ofono"/><allow send_destination="org.ofono"/></policy>
</busconfig>
''')
write('etc/systemd/system/ofono.service', '''[Unit]
Description=Tundra modem diagnostic service
Requires=tundra-hal.service dbus.service
After=tundra-hal.service dbus.service
[Service]
Type=dbus
BusName=org.ofono
ExecStartPre=/usr/bin/binder-wait android.hardware.radio@1.0::IRadio/slot1
ExecStart=/usr/sbin/ofonod --nodetach
Restart=on-failure
RestartSec=5
''')
write('etc/systemd/system/tundra-radio.service', '''[Unit]
Description=Tundra restricted oFono bridge
Requires=dbus.service ofono.service
After=dbus.service ofono.service
[Service]
Type=simple
ExecStart=/usr/bin/python3 /etc/tundra/radio-control.py --server
RuntimeDirectory=tundra-radio
RuntimeDirectoryMode=0755
Restart=on-failure
RestartSec=2
NoNewPrivileges=yes
ProtectHome=yes
ProtectSystem=strict
StateDirectory=tundra/sms
StateDirectoryMode=0700
ReadWritePaths=/run/tundra-radio /var/lib/tundra/sms
''')
write('etc/pulse/client.conf', 'autospawn = no\n')
write('etc/pulse/daemon.conf', 'exit-idle-time = -1\ndefault-sample-rate = 48000\n')
write('etc/pulse/default.pa', '''.fail
load-module module-native-protocol-unix
load-module module-droid-card rate=48000 voice_virtual_stream=true
load-module module-droid-hidl
load-module module-default-device-restore
load-module module-suspend-on-idle
''')
# Prefer AAL for the next camera integration tests. The GStreamer probe
# requests libstagefright_ccodec.so, absent from this Android 9 image.
# Selecting AAL alone does not prove that a preview reaches the sensor.
write('usr/lib/droidian/device/droidian-camera.conf',
'[General]\nblacklist=""\nbackend=aal\n')
write('etc/systemd/system/tundra-audio.service', '''[Unit]
Description=Tundra Sargo PulseAudio HAL bridge
Requires=tundra-hal.service systemd-logind.service
After=tundra-hal.service systemd-logind.service
[Service]
User=droidian
PAMName=tundra
Environment=XDG_RUNTIME_DIR=/run/user/32011
ExecStart=/usr/bin/pulseaudio -n --daemonize=no --exit-idle-time=-1 --log-target=journal --file=/etc/pulse/default.pa
Restart=on-failure
RestartSec=5
''')
write('etc/systemd/system/tundra-call-audio.service', '''[Unit]
Description=Tundra modem call audio routing
Requires=ofono.service tundra-audio.service
After=ofono.service tundra-audio.service
[Service]
ExecStart=/usr/bin/python3 /etc/tundra/call-audio.py
RuntimeDirectory=tundra-call-audio
Restart=on-failure
RestartSec=5
ProtectHome=yes
ProtectSystem=strict
ReadWritePaths=/run/tundra-call-audio
''')
(root/'var/lib/tundra/sms').mkdir(parents=True,exist_ok=True,mode=0o700)
write('etc/systemd/system/tundra-sms.service', '\n'.join([
'[Unit]','Description=Persistent incoming SMS inbox','Requires=ofono.service',
'After=ofono.service','[Service]',
'ExecStart=/usr/bin/python3 /etc/tundra/sms-inbox.py',
'UMask=0077','ProtectSystem=strict','ProtectHome=yes',
'StateDirectory=tundra/sms','StateDirectoryMode=0700',
'ReadWritePaths=/var/lib/tundra/sms','Restart=on-failure','RestartSec=3','']))
wants = root/'etc/systemd/system/tundra.target.wants'
for name in ('ofono', 'tundra-audio', 'ModemManager', 'tundra-radio', 'tundra-call-audio', 'tundra-sms'):
(wants/(name+'.service')).symlink_to('../'+name+'.service')

View file

@ -0,0 +1,240 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Generate native trial configuration inside an already staged runtime tree."""
import argparse
import runpy
from pathlib import Path
import shutil
def prepare(root, network=None, hardware=None, diagnostics=False):
if hardware is None:
hardware = (root/'usr/sbin/ofonod').is_file()
if network is None:
network = (root/'usr/sbin/NetworkManager').is_file()
if hardware and not network:
raise ValueError('Hardware diagnostics require the isolated NV/network setup')
if not (root/'opt/vulpes/trial/session.py').is_file():raise ValueError('Stage the common product first')
def write(name,text,mode=0o644):
p=root/name;p.parent.mkdir(parents=True,exist_ok=True);p.write_text(text);p.chmod(mode)
fonts = Path(__file__).resolve().parents[2]/'gaia/shared/elements/vulpes-fonts'
destination = root/'usr/share/fonts/opentype/vulpes'
destination.mkdir(parents=True, exist_ok=True)
for source in sorted(fonts.glob('*.otf')):
shutil.copyfile(source, destination/source.name)
(destination/source.name).chmod(0o644)
for name in ('LICENSE-OFL', 'NOTICE', 'provenance.json'):
shutil.copyfile(fonts/name, destination/name)
write('etc/fonts/conf.d/00-vulpes-sans.conf', '''<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
<fontconfig>
<alias binding="strong"><family>sans-serif</family><prefer><family>Fira Sans</family></prefer></alias>
<alias binding="strong"><family>FiraSans</family><prefer><family>Fira Sans</family></prefer></alias>
</fontconfig>
''')
write('etc/systemd/system/tundra-display.service.d/fonts.conf', '''[Service]
ExecStartPre=+/usr/bin/fc-cache -f /usr/share/fonts/opentype/vulpes
''')
write('etc/os-release','NAME="Tundra"\nID=tundra\nPRETTY_NAME="Tundra Sargo development trial"\n')
write('etc/hostname','vulpes-sargo\n')
write('etc/shells','/bin/sh\n/bin/bash\n/usr/bin/bash\n')
write('etc/pam.d/tundra','auth required pam_permit.so\naccount required pam_permit.so\nsession required pam_systemd.so\n')
write('etc/pam.d/other','auth required pam_deny.so\naccount required pam_deny.so\npassword required pam_deny.so\nsession required pam_deny.so\n')
write('etc/pam.d/common-account','account required pam_unix.so\n')
write('etc/pam.d/common-session-noninteractive','session required pam_unix.so\n')
write('etc/systemd/logind.conf','[Login]\nHandlePowerKey=ignore\nIdleAction=ignore\n')
write('etc/systemd/journald.conf','[Journal]\nStorage=volatile\nRuntimeMaxUse=32M\n')
write('etc/passwd','root:x:0:0:root:/root:/bin/bash\ndroidian:x:32011:32011:Vulpes trial:/home/droidian:/bin/bash\nsystem:x:1000:1000:Android HAL:/:/usr/sbin/nologin\n')
write('etc/group','root:x:0:\ndroidian:x:32011:\nsystem:x:1000:droidian\ngraphics:x:1003:droidian\nandroid_input:x:1004:droidian\nvideo:x:44:droidian\nrender:x:105:droidian\ninput:x:104:droidian\nnetdev:x:988:\n')
write('etc/shadow','root:!:20000:0:99999:7:::\ndroidian:!:20000:0:99999:7:::\nsystem:!:20000:0:99999:7:::\n',0o600)
write('etc/tundra/phoc.ini','[output:HWCOMPOSER-1]\nscale=3\n')
write('etc/udev/rules.d/90-tundra-display.rules','KERNEL=="kgsl*", GROUP="graphics", MODE="0660"\nKERNEL=="ion", GROUP="graphics", MODE="0660"\nKERNEL=="event*", GROUP="input", MODE="0660"\nKERNEL=="*binder", MODE="0666"\n')
# The Android camera subdevices are not generic V4L capture devices. The
# desktop v4l_id probe opens them before their HAL and crashes this kernel.
(root/'etc/udev/rules.d/60-persistent-v4l.rules').symlink_to('/dev/null')
write('etc/systemd/system/tundra.target','''[Unit]
Description=Tundra temporary hardware trial
Requires=basic.target tundra-display.service
After=basic.target tundra-display.service
AllowIsolate=yes
''')
# systemd does not consistently honor a --unit argument passed through
# switch_root on the Android-derived initramfs. Make the trial target the
# normal boot target as well, so the display starts without an SSH nudge.
default_target = root/'etc/systemd/system/default.target'
if default_target.exists() or default_target.is_symlink():
default_target.unlink()
default_target.symlink_to('tundra.target')
write('etc/systemd/system/tundra-hal.service','''[Unit]
Description=Curated Android display HAL
After=systemd-udev-trigger.service
[Service]
Type=simple
ExecStartPre=/usr/bin/python3 /etc/tundra/setup-hal.py
ExecStart=/usr/bin/python3 /etc/tundra/lxc-guard.py -F -n tundra-hal -f /etc/tundra/lxc.conf -o /run/tundra/lxc.log -l DEBUG
KillMode=control-group
TimeoutStopSec=15
''')
write('etc/systemd/system/tundra-display.service','''[Unit]
Description=Gaia and Gecko on the Sargo display
Requires=tundra-hal.service dbus.service systemd-logind.service
After=tundra-hal.service dbus.service systemd-logind.service systemd-user-sessions.service
Wants=systemd-user-sessions.service
[Service]
User=droidian
PAMName=tundra
WorkingDirectory=/home/droidian
Environment=XDG_RUNTIME_DIR=/run/user/32011
Environment=XDG_SESSION_TYPE=wayland
Environment=XDG_CURRENT_DESKTOP=Vulpes
Environment=LANG=C.UTF-8
Environment=VULPES_TUNDRA=1
Environment=VULPES_TUNDRA_RADIO=1
Environment=WLR_BACKENDS=hwcomposer,libinput
Environment=EGL_PLATFORM=hwcomposer
Environment=WLR_HWC_SKIP_VERSION_CHECK=1
ExecStartPre=+/usr/bin/python3 /etc/tundra/wait-hal.py
ExecStartPre=+chvt 7
ExecStart=/usr/bin/dbus-run-session -- /usr/bin/phoc --no-xwayland --socket=tundra-0 -C /etc/tundra/phoc.ini -E "/usr/bin/python3 /opt/vulpes/trial/session.py --own-compositor --hybris-egl"
TTYPath=/dev/tty7
TTYReset=yes
TTYVHangup=yes
StandardInput=tty-fail
StandardOutput=journal
StandardError=inherit
KillMode=control-group
TimeoutStartSec=90
TimeoutStopSec=15
''')
if diagnostics:
display = root/'etc/systemd/system/tundra-display.service'
display.write_text(display.read_text().replace('--own-compositor --hybris-egl',
'--own-compositor --hybris-egl --diagnostics'))
write('etc/tundra/wait-hal.py','''import subprocess,time
for _ in range(120):
p=subprocess.run(['/usr/bin/lxc-attach','-n','tundra-hal','--','/system/bin/getprop','init.svc.vendor.hwcomposer-2-2'],capture_output=True,text=True)
if p.stdout.strip()=='running':break
time.sleep(.5)
else:raise RuntimeError('Display HAL did not start')
''')
write('etc/tundra/lxc.conf','''lxc.rootfs.path = /android
lxc.uts.name = tundra-hal
lxc.net.0.type = empty
lxc.arch = aarch64
lxc.init.cmd = /init
lxc.apparmor.profile = unconfined
lxc.autodev = 0
lxc.cap.drop = mac_admin mac_override sys_boot
lxc.cgroup.devices.deny = b *:* rwm
lxc.cgroup2.devices.deny = b *:* rwm
lxc.cgroup2.memory.max = 536870912
lxc.mount.entry = tmpfs dev tmpfs nosuid 0 0
lxc.mount.entry = /dev/__properties__ dev/__properties__ none bind,create=dir 0 0
lxc.mount.entry = /dev/socket dev/socket none bind,create=dir 0 0
lxc.mount.entry = proc proc proc nodev,noexec,nosuid 0 0
lxc.mount.entry = sys sys sysfs nodev,noexec,nosuid 0 0
lxc.mount.entry = /android/data data none bind 0 0
lxc.mount.entry = /mnt mnt none rbind 0 0
''')
for name in ('hal_policy.py','setup-hal.py','lxc-guard.py','hardware-status.py','prepare-network.py','wifi-control.py','radio-control.py','sms-inbox.py','call-audio.py','camera-preview.py','buttons.py'):
shutil.copy2(Path(__file__).parent/name,root/'etc/tundra'/name)
write('etc/systemd/system/tundra-hardware.service', '''[Unit]
Description=Tundra read-only battery and network state
After=systemd-udev-trigger.service
[Service]
ExecStart=/usr/bin/python3 /etc/tundra/hardware-status.py
RuntimeDirectory=tundra-hardware
RuntimeDirectoryMode=0755
Restart=on-failure
RestartSec=5
NoNewPrivileges=yes
''')
wants = root/'etc/systemd/system/tundra.target.wants'
wants.mkdir(exist_ok=True)
(wants/'tundra-hardware.service').symlink_to('../tundra-hardware.service')
write('etc/systemd/system/tundra-buttons.service', '''[Unit]
Description=Sargo physical keys, backlight and audio volume
After=systemd-udev-trigger.service
[Service]
ExecStartPre=/usr/bin/python3 /etc/tundra/buttons.py --stop-vibration
ExecStart=/usr/bin/python3 /etc/tundra/buttons.py
ExecStopPost=/usr/bin/python3 /etc/tundra/buttons.py --stop-vibration
RuntimeDirectory=tundra-buttons
RuntimeDirectoryMode=0755
Restart=on-failure
RestartSec=2
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/run/tundra-buttons /sys/class/backlight -/sys/class/leds/vibrator
NoNewPrivileges=yes
''')
(wants/'tundra-buttons.service').symlink_to('../tundra-buttons.service')
# Older immutable images may not contain the networking packages yet.
if network:
write('etc/tundra/network-enabled', 'sargo\n')
display = root/'etc/systemd/system/tundra-display.service'
display.write_text(display.read_text().replace('Environment=VULPES_TUNDRA=1',
'Environment=VULPES_TUNDRA=1\nEnvironment=VULPES_TUNDRA_WIFI=1'))
with (root/'etc/tundra/lxc.conf').open('a') as config:
config.write('lxc.mount.entry = /run/tundra/nv-block dev/block none bind,create=dir 0 0\n')
write('etc/NetworkManager/NetworkManager.conf', '''[main]
plugins=keyfile
dhcp=internal
dns=default
auth-polkit=root-only
[keyfile]
unmanaged-devices=*,except:interface-name:wlan0
[device]
wifi.scan-rand-mac-address=no
[connection]
wifi.cloned-mac-address=permanent
[connectivity]
enabled=false
''')
write('etc/systemd/system/tundra-network.service', '''[Unit]
Description=Sargo Wi-Fi driver
Requires=tundra-hal.service
After=tundra-hal.service
Before=NetworkManager.service
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /etc/tundra/prepare-network.py
TimeoutStartSec=180
RemainAfterExit=yes
''')
write('etc/systemd/system/NetworkManager.service.d/tundra.conf', '''[Unit]
Requires=tundra-network.service
After=tundra-network.service
''')
# Wi-Fi profiles live in the private persistent root overlay.
(wants/'NetworkManager.service').symlink_to('/usr/lib/systemd/system/NetworkManager.service')
(root/'etc/systemd/system/dbus-fi.w1.wpa_supplicant1.service').symlink_to('/usr/lib/systemd/system/wpa_supplicant.service')
(root/'etc/NetworkManager/system-connections').mkdir(parents=True, exist_ok=True)
write('etc/systemd/system/tundra-wifi.service', '''[Unit]
Description=Vulpes native Wi-Fi control
Requires=NetworkManager.service
After=NetworkManager.service
[Service]
ExecStart=/usr/bin/python3 /etc/tundra/wifi-control.py
Group=droidian
RuntimeDirectory=tundra-wifi
RuntimeDirectoryMode=0750
NoNewPrivileges=yes
PrivateTmp=yes
ProtectHome=yes
ProtectSystem=strict
ReadWritePaths=/run /etc/NetworkManager/system-connections
Restart=on-failure
RestartSec=5
''')
(wants/'tundra-wifi.service').symlink_to('../tundra-wifi.service')
if hardware:
runpy.run_path(str(Path(__file__).with_name('prepare-hardware.py')))['prepare'](root, write)
# Generate the initial machine identity locally. Persistent boots retain it.
write('etc/machine-id', '')
for directory in [root/'etc', *[p for p in (root/'etc').rglob('*') if p.is_dir()]]:
directory.chmod(0o755)
if __name__=='__main__':
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--root',type=Path,required=True);a=p.parse_args();prepare(a.root.resolve())

View file

@ -0,0 +1,40 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Activate the Sargo WLAN driver after the read-only vendor mount exists."""
from pathlib import Path
import errno
import time
def main():
if 'tundra.rescue=1' not in Path('/proc/cmdline').read_text():
raise RuntimeError('Temporary Tundra boot required')
firmware = Path('/vendor/firmware')
if not firmware.is_dir():
raise RuntimeError('Vendor firmware not mounted')
Path('/sys/module/firmware_class/parameters/path').write_text(str(firmware))
boot = Path('/sys/kernel/boot_wlan/boot_wlan')
if boot.exists():
try:
boot.write_text('1\n')
except OSError as error:
if error.errno != errno.EALREADY:
raise
# pd-mapper publishes the Qualcomm WLAN protection domain. Its firmware
# can take a minute to start after the display HAL is ready.
for _ in range(180):
if Path('/sys/class/net/wlan0').exists():
return
time.sleep(.5)
wlan = Path('/dev/wlan')
if wlan.exists():
wlan.write_text('ON\n')
for _ in range(60):
if Path('/sys/class/net/wlan0').exists():
return
time.sleep(.5)
raise RuntimeError('Sargo WLAN driver did not expose wlan0')
if __name__ == '__main__':
main()

View file

@ -0,0 +1,186 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Small, allow-listed oFono broker used by the native Tundra session.
The Gaia process never receives access to the system D-Bus modem service. The
root service accepts one JSON request per Unix-socket connection and invokes
only fixed oFono methods with validated values. This keeps the diagnostic
image useful while avoiding a general purpose command bridge.
"""
import json
import importlib.util
import os
from pathlib import Path
import re
import socket
import subprocess
import sys
import time
SOCKET = Path('/run/tundra-radio/radio.sock')
MODEM = '/ril_0'
NUMBER = re.compile(r'^[0-9+#*(), .-]{3,32}$')
VOICE = re.compile(r'^/ril_0/voicecall[0-9]+$')
def bus(*args):
"""Call one fixed system-bus method and return busctl's JSON data."""
result = subprocess.run(
['/usr/bin/busctl', '--system', '--json=short', 'call', *args],
capture_output=True, text=True, timeout=15, check=False,
)
if result.returncode:
# Keep modem errors distinct from transport failures, without returning
# command lines, phone numbers or subscriber identifiers to Gaia.
detail = result.stderr.lower()
if 'not registered' in detail or 'notregistered' in detail:
raise RuntimeError('RADIO_NOT_REGISTERED')
if 'in progress' in detail or 'inprogress' in detail or 'busy' in detail:
raise RuntimeError('RADIO_BUSY')
if 'not available' in detail or 'notavailable' in detail:
raise RuntimeError('RADIO_NOT_READY')
raise RuntimeError('RADIO_REQUEST_FAILED')
# Successful void D-Bus methods (Hangup, Answer) produce no JSON in busctl.
if not result.stdout.strip():
return None
try:
payload = json.loads(result.stdout)
return payload.get('data')
except (ValueError, AttributeError):
raise RuntimeError('INVALID_OFONO_REPLY')
def request(value):
if not isinstance(value, dict) or value.get('version') != 1:
raise ValueError('INVALID_REQUEST')
operation = value.get('operation')
if operation in ('incomingMessages','ackMessages','messageState'):
spec = importlib.util.spec_from_file_location('inbox',Path(__file__).with_name('sms-inbox.py'))
inbox = importlib.util.module_from_spec(spec);spec.loader.exec_module(inbox)
if operation == 'messageState':
path = value.get('path', '')
if not isinstance(path,str) or not re.fullmatch(r'/ril_0/message[0-9a-f_]+',path):
raise ValueError('INVALID_SMS_PATH')
state = inbox.outgoing_state(path)
if state is None:
try:
props = bus('org.ofono',path,'org.ofono.Message','GetProperties')[0]
state = props.get('State',{}).get('data','unknown')
except RuntimeError: state = 'unknown'
return {'state':state if state in ('pending','sent','failed') else 'unknown'}
return inbox.pending() if operation == 'incomingMessages' else inbox.acknowledge(value.get('ids'))
if operation == 'calls':
data = bus('org.ofono', MODEM, 'org.ofono.VoiceCallManager', 'GetCalls')
calls = []
for path, properties in data[0]:
if not VOICE.fullmatch(path):
continue
def field(name):
item = properties.get(name, {})
return item.get('data') if isinstance(item, dict) else item
state = field('State')
if state not in ('dialing', 'alerting', 'active', 'held', 'incoming', 'waiting', 'disconnected'):
continue
calls.append({'path': path, 'state': state,
'number': str(field('LineIdentification') or '')[:80]})
return calls
if operation == 'status':
return {
'modem': bus('org.ofono', MODEM, 'org.ofono.Modem', 'GetProperties'),
'registration': bus('org.ofono', MODEM, 'org.ofono.NetworkRegistration', 'GetProperties'),
}
if operation == 'dial':
number = str(value.get('number', '')).strip()
if not NUMBER.fullmatch(number):
raise ValueError('INVALID_NUMBER')
path = bus('org.ofono', MODEM, 'org.ofono.VoiceCallManager',
'Dial', 'ss', number, 'default')
return {'path': path[0] if isinstance(path, list) and path else path,
'startedAt': time.time()}
if operation in ('hangup', 'answer'):
path = value.get('path', '')
if not isinstance(path, str) or not VOICE.fullmatch(path):
raise ValueError('INVALID_CALL')
bus('org.ofono', path, 'org.ofono.VoiceCall',
'Hangup' if operation == 'hangup' else 'Answer')
return {'path': path}
if operation == 'sms':
number = str(value.get('number', '')).strip()
body = value.get('body', '')
if not NUMBER.fullmatch(number) or not isinstance(body, str) or not body or len(body) > 1600:
raise ValueError('INVALID_SMS')
registration = bus('org.ofono', MODEM, 'org.ofono.NetworkRegistration', 'GetProperties')[0]
if registration.get('Status',{}).get('data') not in ('registered','roaming'):
raise RuntimeError('RADIO_NOT_REGISTERED')
result = bus('org.ofono', MODEM, 'org.ofono.MessageManager',
'SendMessage', 'ss', number, body)
return {'path': result[0] if isinstance(result, list) and result else result,
'state':'pending', 'number': number, 'bodyLength': len(body), 'submittedAt': time.time()}
raise ValueError('NOT_SUPPORTED')
def reply(stream, value):
stream.write((json.dumps(value, separators=(',', ':')) + '\n').encode())
stream.flush()
def serve():
SOCKET.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
try:
SOCKET.unlink()
except FileNotFoundError:
pass
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server:
server.bind(str(SOCKET))
os.chmod(SOCKET, 0o660)
try:
import grp
os.chown(SOCKET, 0, grp.getgrnam('droidian').gr_gid)
except (KeyError, OSError):
pass
server.listen(8)
while True:
conn, _ = server.accept()
with conn:
conn.settimeout(20)
line = b''
while not line.endswith(b'\n') and len(line) < 8192:
chunk = conn.recv(1024)
if not chunk:
break
line += chunk
try:
result = request(json.loads(line.decode()))
reply(conn.makefile('wb'), {'ok': True, 'result': result})
except (ValueError, RuntimeError, OSError, subprocess.TimeoutExpired) as error:
reply(conn.makefile('wb'), {'ok': False, 'error': str(error) or 'RADIO_ERROR'})
def client(envelope=False):
raw = sys.stdin.read(8192)
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.settimeout(20)
sock.connect(str(SOCKET))
sock.sendall(raw.encode() + (b'' if raw.endswith('\n') else b'\n'))
data = b''
while not data.endswith(b'\n') and len(data) < 65536:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
result = json.loads(data.decode())
if envelope:
print(json.dumps(result, separators=(',', ':')))
return
if not result.get('ok'):
raise RuntimeError(result.get('error', 'RADIO_ERROR'))
print(json.dumps(result.get('result'), separators=(',', ':')))
if __name__ == '__main__':
if '--server' in sys.argv:
serve()
elif '--client' in sys.argv or '--client-envelope' in sys.argv:
client(envelope='--client-envelope' in sys.argv)
else:
raise SystemExit('use --server or --client')

View file

@ -0,0 +1,86 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Mount the inventoried Sargo HAL read-only, with disposable init/data overlays."""
import hashlib
import json
import os
import re
from pathlib import Path
import subprocess
import hal_policy
from hal_policy import filter_rc, is_init_script
def run(*args):subprocess.run(args,check=True)
def directory(p):Path(p).mkdir(parents=True,exist_ok=True)
def overlay(lower,target,label):
upper='/run/tundra/'+label+'-upper';work='/run/tundra/'+label+'-work'
for p in (upper,work,target):directory(p)
run('mount','-t','overlay','overlay','-o',f'lowerdir={lower},upperdir={upper},workdir={work}',target)
def main():
if os.getuid()!=0:raise RuntimeError('HAL mounts require root')
# This service only runs in the temporary boot assembled by rescue-init.
if 'tundra.rescue=1' not in Path('/proc/cmdline').read_text():raise RuntimeError('Temporary boot flag missing')
directory('/var/lib/lxc')
if Path('/run/tundra/hal-ready').is_file():return
network = Path('/etc/tundra/network-enabled').is_file()
if network:
# Never let the experimental modem firmware write the installed NV.
# rmt_storage opens these paths as files; no block device is exposed.
destination = Path('/run/tundra/nv-block/bootdevice/by-name')
destination.mkdir(parents=True, exist_ok=True, mode=0o700)
partitions = {'modemst1': (46,2097152), 'modemst2': (47,2097152),
'fsg': (12,2097152), 'fsc': (42,131072)}
for name, (number, size) in partitions.items():
device = 'mmcblk0p'+str(number)
sectors = int(Path('/sys/class/block',device,'size').read_text())
if sectors*512 != size:
raise RuntimeError('Unexpected NV partition size: '+name)
with open('/dev/'+device,'rb',buffering=0) as source:
data = source.read(size)
if len(data) != size:raise RuntimeError('Incomplete NV read: '+name)
target = destination/name
target.write_bytes(data)
target.chmod(0o600)
hal_policy.SERVICES = hal_policy.SERVICES | hal_policy.NETWORK_SERVICES
if Path('/etc/tundra/hardware-enabled').is_file():
hal_policy.SERVICES = hal_policy.SERVICES | hal_policy.HARDWARE_SERVICES
for p in ('/run/tundra/android-original','/run/tundra/vendor-original','/dev/__properties__','/dev/socket','/mnt/vendor/persist'):
directory(p)
image=Path('/reference/android-rootfs.img')
with image.open('rb') as f:sha=hashlib.file_digest(f,'sha256').hexdigest()
if sha!='132d0e18c6a6a111293447e940267f35aa2b14fa00a872ef160b20dc2014d886':raise RuntimeError('Android reference changed')
run('mount','-t','ext4','-o','loop,ro,noload',str(image),'/run/tundra/android-original')
run('mount','-t','ext4','-o','ro,noload','/dev/mmcblk0p70','/run/tundra/vendor-original')
run('mount','-t','ext4','-o','ro,noload','/dev/mmcblk0p48','/mnt/vendor/persist')
overlay('/run/tundra/android-original','/android','android')
overlay('/run/tundra/vendor-original','/android/vendor','vendor')
if network:
# IPA firmware is not initialized in the minimal HAL. Enabling its WLAN
# offload dereferences a null ipahal context when the interface comes up.
config = Path('/android/vendor/firmware/wlan/qca_cld/WCNSS_qcom_cfg.ini')
text, count = re.subn(r'^gIPAConfig=.*$', 'gIPAConfig=0', config.read_text(), flags=re.M)
if count != 1:raise RuntimeError('Expected exactly one Sargo WLAN IPA setting')
config.write_text(text) # vendor overlay upper is tmpfs, never the partition
directory('/android/vendor/firmware_mnt')
run('mount','-t','vfat','-o','ro','/dev/mmcblk0p21','/android/vendor/firmware_mnt')
report={}
for p in sorted(Path('/android').rglob('*.rc')):
if not p.is_file() or p.is_symlink() or not is_init_script(p):continue
text=p.read_text();filtered,record=filter_rc(text);record['sourceSha256']=hashlib.sha256(text.encode()).hexdigest()
p.write_text(filtered);report[str(p)]=record
(Path('/run/tundra')/'hal-policy-report.json').write_text(json.dumps(report,indent=2))
# The container cannot open block devices. Its data and metadata live in RAM.
for p in ('/android/data','/android/metadata'):
run('mount','-t','tmpfs','-o','mode=0771','tmpfs',p)
for p in ('/system','/vendor'):
path=Path(p)
if not path.is_symlink():
if path.exists():path.rmdir()
path.symlink_to('/android'+p)
Path('/run/tundra/hal-ready').touch()
if __name__=='__main__':main()

View file

@ -0,0 +1,79 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Keep oFono incoming messages until Gecko durably acknowledges them."""
import fcntl
import json
import os
from pathlib import Path
import time
import uuid
ROOT = Path('/var/lib/tundra/sms')
def transaction(change, root=ROOT, filename="inbox.json"):
if filename not in ("inbox.json", "outgoing.json"): raise ValueError("INVALID_SMS_STORE")
root.mkdir(parents=True, mode=0o700, exist_ok=True)
with (root/'lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
path = root/filename
rows = json.loads(path.read_text()) if path.exists() else []
result = change(rows)
temporary = path.with_suffix('.tmp')
with open(temporary, 'w', opener=lambda p, f: os.open(p, f, 0o600)) as stream:
os.fchmod(stream.fileno(),0o600)
json.dump(rows,stream)
stream.flush(); os.fsync(stream.fileno())
temporary.replace(path)
return result
def pending():
return transaction(lambda rows: rows[:100])
def acknowledge(ids):
if not isinstance(ids,list) or len(ids)>100 or any(not isinstance(i,str) or len(i)>40 for i in ids):
raise ValueError('INVALID_SMS_IDS')
def remove(rows):
rows[:] = [row for row in rows if row['receipt'] not in ids]
transaction(remove)
return True
def outgoing_state(path):
rows = transaction(lambda rows: list(rows), filename='outgoing.json')
return next((row['state'] for row in reversed(rows) if row['path'] == path), None)
def record_outgoing(path, state):
if state not in ('sent','failed'): return
def update(rows):
rows[:] = [row for row in rows if row['path'] != path][-999:]
rows.append({'path':path,'state':state,'timestamp':round(time.time()*1000)})
transaction(update, filename='outgoing.json')
def main():
import dbus
from dbus.mainloop.glib import DBusGMainLoop
from gi.repository import GLib
os.umask(0o077)
DBusGMainLoop(set_as_default=True)
bus = dbus.SystemBus()
def incoming(body, properties):
row = {'receipt':str(uuid.uuid4()),'sender':str(properties.get('Sender','')),
'body':str(body),'timestamp':round(time.time()*1000)}
transaction(lambda rows:rows.append(row))
bus.add_signal_receiver(incoming,signal_name='IncomingMessage',
dbus_interface='org.ofono.MessageManager',bus_name='org.ofono',path='/ril_0')
def outgoing(name, value, path=None):
if name == 'State' and path and str(path).startswith('/ril_0/message'):
record_outgoing(str(path), str(value))
bus.add_signal_receiver(outgoing, signal_name='PropertyChanged',
dbus_interface='org.ofono.Message', bus_name='org.ofono', path_keyword='path')
GLib.MainLoop().run()
if __name__ == '__main__':
main()

View file

@ -0,0 +1,275 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Narrow native Wi-Fi bridge. Credentials travel over a private Unix socket."""
import argparse
import configparser
import json
import os
from pathlib import Path
import re
import socket
import struct
import subprocess
import sys
import uuid
SOCKET = '/run/tundra-wifi/control.sock'
MAX_REQUEST = 16384
DEVICE = 'wlan0'
PROFILE_DIR = Path('/etc/NetworkManager/system-connections')
class WifiError(Exception):
pass
def fields(line):
"""Decode nmcli's escaped colon-separated output, including SSIDs."""
result, value, escaped = [], '', False
for char in line:
if escaped:
value += char
escaped = False
elif char == '\\':
escaped = True
elif char == ':':
result.append(value)
value = ''
else:
value += char
if escaped:
value += '\\'
return result + [value]
def nm(*args, timeout=45):
try:
return subprocess.run(['/usr/bin/nmcli', '--wait', '30', *args],
capture_output=True, text=True, timeout=timeout,
env={'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'LC_ALL': 'C'})
except (OSError, subprocess.TimeoutExpired):
raise WifiError('WIFI_UNAVAILABLE') from None
def networks(rescan=False):
result = nm('-t', '--escape', 'yes', '-f', 'DBUS-PATH,SSID,SIGNAL,SECURITY,IN-USE',
'device', 'wifi', 'list', 'ifname', DEVICE,
'--rescan', 'yes' if rescan else 'no')
if result.returncode:
raise WifiError('WIFI_UNAVAILABLE')
found = []
for line in result.stdout.splitlines():
parts = fields(line)
if len(parts) != 5:
continue
ident, ssid, signal, security, active = parts
if not re.fullmatch(r'/org/freedesktop/NetworkManager/AccessPoint/\d+', ident) or not ssid:
continue
if not signal.isdigit() or not 0 <= int(signal) <= 100:
continue
if not security or security == '--':
mode = 'open'
elif '802.1X' in security or 'EAP' in security:
mode = 'unsupported'
elif 'WPA3' in security and 'WPA2' not in security:
mode = 'sae'
elif 'WPA2' in security or 'WPA1' in security or security == 'WPA':
mode = 'wpa-psk'
else:
mode = 'unsupported'
found.append({'id': ident, 'ssid': ssid, 'signal': int(signal),
'security': mode, 'connected': active == '*'})
return sorted(found, key=lambda row: -row['signal'])[:128]
def saved_profiles(profile_dir):
result = []
for path in profile_dir.glob('vulpes-*.nmconnection'):
try:
if path.is_symlink():
continue
parser = configparser.ConfigParser(interpolation=None)
parser.read(path)
uid = str(uuid.UUID(parser['connection']['uuid']))
if path.name != 'vulpes-' + uid + '.nmconnection':
continue
ssid = bytes(int(b) for b in parser['wifi']['ssid'].split(';') if b).decode()
mode = parser.get('wifi-security', 'key-mgmt', fallback='open')
result.append({'id': uid, 'ssid': ssid, 'security': mode, 'known': True})
except (OSError, ValueError, KeyError, configparser.Error):
continue
return result
def private_write(target, text):
temporary = target.with_suffix('.tmp')
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600)
with os.fdopen(fd, 'w') as stream:
os.fchmod(stream.fileno(), 0o600)
stream.write(text)
stream.flush()
os.fsync(stream.fileno())
temporary.replace(target)
def profile_text(network, password, uid):
ssid = network['ssid']
if not isinstance(ssid, str) or not 1 <= len(ssid.encode()) <= 32:
raise WifiError('WIFI_INVALID_REQUEST')
mode = network['security']
if mode not in ('open', 'wpa-psk', 'sae'):
raise WifiError('WIFI_UNSUPPORTED_SECURITY')
if not isinstance(password, str) or any(ord(c) < 32 for c in password):
raise WifiError('WIFI_INVALID_PASSWORD')
length = len(password.encode())
if mode == 'wpa-psk' and not (8 <= length <= 63 or re.fullmatch(r'[0-9a-fA-F]{64}', password)):
raise WifiError('WIFI_INVALID_PASSWORD')
if mode == 'sae' and not 1 <= length <= 63:
raise WifiError('WIFI_INVALID_PASSWORD')
text = (f'[connection]\nid=Vulpes Wi-Fi\nuuid={uid}\ntype=wifi\n'
'interface-name=wlan0\nautoconnect=true\n[wifi]\nmode=infrastructure\n'
'cloned-mac-address=permanent\nssid=' +
''.join(str(b) + ';' for b in ssid.encode()) +
'\n[ipv4]\nmethod=auto\n[ipv6]\nmethod=auto\n')
if mode != 'open':
escaped = password.replace('\\', '\\\\').replace(' ', '\\s')
text += f'[wifi-security]\nkey-mgmt={mode}\npsk={escaped}\n'
return text
def dispatch(request, profile_dir=PROFILE_DIR):
if not isinstance(request, dict) or set(request) - {'operation', 'id', 'password', 'enabled'}:
raise WifiError('WIFI_INVALID_REQUEST')
operation = request.get('operation')
saved = saved_profiles(profile_dir)
if operation == 'known':
return {'networks': saved}
if operation == 'forget':
network = next((row for row in saved if row['id'] == request.get('id')), None)
if network is None:
raise WifiError('WIFI_NETWORK_GONE')
uid = network['id']
if nm('connection', 'delete', 'uuid', uid).returncode:
raise WifiError('WIFI_FORGET_FAILED')
(profile_dir / ('vulpes-' + uid + '.nmconnection')).unlink(missing_ok=True)
return {'forgotten': True}
if operation in ('status', 'scan', 'setEnabled'):
if operation == 'setEnabled':
enabled = request.get('enabled')
if type(enabled) is not bool:
raise WifiError('WIFI_INVALID_REQUEST')
if nm('radio', 'wifi', 'on' if enabled else 'off').returncode:
raise WifiError('WIFI_UNAVAILABLE')
radio = nm('radio', 'wifi')
if radio.returncode:
raise WifiError('WIFI_UNAVAILABLE')
enabled = radio.stdout.strip() == 'enabled'
found = networks(rescan=True) if enabled and operation == 'scan' else []
for network in found:
stored = next((row for row in saved if row['ssid'] == network['ssid'] and row['security'] == network['security']), None)
network.update(known=stored is not None, savedId=stored['id'] if stored else None)
return {'enabled': enabled, 'networks': found}
if operation == 'disconnect':
if nm('device', 'disconnect', DEVICE).returncode:
raise WifiError('WIFI_DISCONNECT_FAILED')
return {'disconnected': True}
if operation != 'connect':
raise WifiError('WIFI_INVALID_REQUEST')
# Resolve an observed AP; clients cannot supply nmcli arguments or arbitrary SSIDs.
network = next((row for row in networks() if row['id'] == request.get('id')), None)
if network is None:
raise WifiError('WIFI_NETWORK_GONE')
stored = next((row for row in saved if row['ssid'] == network['ssid'] and row['security'] == network['security']), None)
uid = stored['id'] if stored else str(uuid.uuid4())
target = profile_dir / ('vulpes-' + uid + '.nmconnection')
previous = target.read_text() if stored else None
text = previous if stored and not request.get('password') else profile_text(network, request.get('password', ''), uid)
profile_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
target = profile_dir / ('vulpes-' + uid + '.nmconnection')
connected = False
try:
private_write(target, text)
if nm('connection', 'load', str(target)).returncode:
raise WifiError('WIFI_UNAVAILABLE')
if nm('connection', 'up', 'uuid', uid).returncode:
raise WifiError('WIFI_CONNECT_FAILED')
connected = True
return {'connected': True}
finally:
if not connected:
# Never include nmcli stderr: it may contain the SSID or credentials.
if previous is not None:
private_write(target, previous)
nm('connection', 'load', str(target))
else:
nm('connection', 'delete', 'uuid', uid)
target.unlink(missing_ok=True)
def receive(stream, limit):
data = bytearray()
while b'\n' not in data:
part = stream.recv(min(4096, limit + 1 - len(data)))
if not part:
raise WifiError('WIFI_INVALID_REQUEST')
data.extend(part)
if len(data) > limit:
raise WifiError('WIFI_INVALID_REQUEST')
if data.count(b'\n') != 1 or not data.endswith(b'\n'):
raise WifiError('WIFI_INVALID_REQUEST')
return json.loads(data)
def handle(stream):
stream.settimeout(5)
try:
_, uid, _ = struct.unpack('3i', stream.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
if uid not in (0, 32011):
raise WifiError('PERMISSION_DENIED')
result = {'result': dispatch(receive(stream, MAX_REQUEST))}
except WifiError as error:
result = {'error': str(error)}
except Exception:
result = {'error': 'WIFI_UNAVAILABLE'}
try:
stream.sendall(json.dumps(result).encode() + b'\n')
except OSError:
pass
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--client', action='store_true')
args = parser.parse_args()
if args.client:
try:
data = sys.stdin.buffer.read(MAX_REQUEST + 1)
if len(data) > MAX_REQUEST:
raise WifiError('WIFI_INVALID_REQUEST')
with socket.socket(socket.AF_UNIX) as stream:
stream.settimeout(90)
stream.connect(SOCKET)
stream.sendall(json.dumps(json.loads(data)).encode() + b'\n')
result = receive(stream, 65536)
except Exception:
result = {'error': 'WIFI_UNAVAILABLE'}
print(json.dumps(result))
return
if os.getuid() or 'tundra.rescue=1' not in Path('/proc/cmdline').read_text():
raise SystemExit('Temporary native boot required')
os.umask(0o077)
path = Path(SOCKET)
path.unlink(missing_ok=True)
with socket.socket(socket.AF_UNIX) as server:
server.bind(SOCKET)
os.chown(SOCKET, 0, 32011)
path.chmod(0o660)
server.listen(4)
while True:
stream, _ = server.accept()
with stream:
handle(stream)
if __name__ == '__main__':
main()