#!/usr/bin/env python3 """FrameBridge's two-pane physical console, using the existing user's tmux. No login, privilege, service or desktop configuration is changed here. The normal entry point is called by an existing authenticated tty1 login. """ from __future__ import annotations import argparse import fcntl import getpass import json import os from pathlib import Path import re import shlex import socket import stat import subprocess import sys import time HERE = Path(__file__).resolve().parent def arguments(): p = argparse.ArgumentParser(description=__doc__) p.add_argument('--node', default=os.environ.get('FRAMEBRIDGE_NODE', socket.gethostname())) p.add_argument('--socket') p.add_argument('--session') p.add_argument('--jobs-root', type=Path) p.add_argument('--apply', action='store_true', help='Set up without attaching a client') p.add_argument('--probe', action='store_true', help='Read current pane metadata only') p.add_argument('--reflow', action='store_true', help='Resize existing panes without taking focus') p.add_argument('--refresh-monitor', action='store_true') p.add_argument('--layout', choices=('auto','horizontal','vertical'), default='auto') p.add_argument('--monitor', action='store_true', help=argparse.SUPPRESS) a = p.parse_args() a.node = re.sub(r'[^A-Za-z0-9_-]', '_', a.node)[:64] or 'NODE' a.socket = a.socket or str(Path('/run/user') / str(os.getuid()) / (a.node.lower() + '-tty1') / 'console.sock') a.session = a.session or a.node.upper() + '-FBTERM' if not re.fullmatch(r'[A-Za-z0-9_-]{1,90}', a.session): p.error('Session name must contain only letters, digits, underscore and hyphen') return a class Console: def __init__(self, args): self.a = args self.session_target = args.session self.window = '=' + args.session + ':0' self.env = {k:v for k,v in os.environ.items() if k not in ('TMUX', 'TMUX_PANE')} self.env['LC_ALL'] = 'C.UTF-8' def tmux(self, *args, check=True): r = subprocess.run(['/usr/bin/tmux', '-S', self.a.socket, *args], env=self.env, capture_output=True, text=True, timeout=8) if check and r.returncode: raise RuntimeError('tmux ' + args[0] + ': ' + r.stderr.strip()) return r.stdout.strip() def exists(self): r = subprocess.run(['/usr/bin/tmux', '-S', self.a.socket, 'has-session', '-t', '=' + self.a.session], env=self.env, capture_output=True, timeout=5) if r.returncode == 0: matches=[line.rsplit('|',1)[1] for line in self.tmux('list-sessions','-F','#{session_name}|#{session_id}').splitlines() if line.rsplit('|',1)[0]==self.a.session] if len(matches)!=1: raise RuntimeError('Console session identity is ambiguous') self.session_target=matches[0] windows=[line.split('|',1) for line in self.tmux( 'list-windows','-t',self.session_target,'-F','#{window_id}|#{window_name}').splitlines()] if not windows or any(len(row)!=2 or not re.fullmatch(r'@[0-9]+',row[0]) for row in windows): raise RuntimeError('Console window identity is invalid') selected=windows if len(windows)==1 else [row for row in windows if row[1]=='Console'] if len(selected)!=1: raise RuntimeError('The dedicated Console window is absent or ambiguous') self.window=selected[0][0] return True return False def panes(self): fmt = '|'.join('#{' + f + '}' for f in ('pane_id','pane_pid','pane_current_command', 'pane_left','pane_top','pane_width','pane_height','pane_active','pane_dead', '@framebridge_role')) rows = [] for line in self.tmux('list-panes', '-t', self.window, '-F', fmt).splitlines(): ident,pid,command,left,top,width,height,active,dead,role = line.split('|') rows.append(dict(id=ident,pid=int(pid),command=command,left=int(left),top=int(top), width=int(width),height=int(height),active=active=='1',dead=dead=='1',role=role)) return rows def monitor_command(self): command=['/usr/bin/python3', str(HERE / 'framebridge_console.py'), '--monitor', '--node', self.a.node] if getattr(self.a,'jobs_root',None) is not None: command.extend(['--jobs-root',str(self.a.jobs_root)]) return shlex.join(command) def locate(self, rows): shell = next((p for p in rows if p['role']=='shell'), None) monitor = next((p for p in rows if p['role']=='monitor'), None) # Adopt only the known original dashboard, never an arbitrary Python pane. for p in rows: if p['role']: continue try: cmd = Path('/proc', str(p['pid']), 'cmdline').read_bytes().split(b'\0') names = {Path(x.decode(errors='replace')).name for x in cmd if x} except OSError: names = set() if not monitor and ('fbterm_live.py' in names or 'fbterm_dashboard.py' in names): monitor = p elif not shell and p['command']=='bash' and not p['dead']: shell = p return shell, monitor def layout(self, shell, monitor): active=next((p['id'] for p in self.panes() if p['active']),None) width,height = map(int, self.tmux('display-message','-p','-t',self.window, '#{window_width} #{window_height}').split()) mode=getattr(self.a,'layout','auto') horizontal=mode=='horizontal' or (mode=='auto' and width>=160) if horizontal: self.tmux('select-layout','-t',self.window,'even-horizontal') current={p['id']:p for p in self.panes()} if current[monitor['id']]['left']>current[shell['id']]['left']: self.tmux('swap-pane','-d','-s',shell['id'],'-t',monitor['id']) monitor_width=max(2,min(max(2,width-21),max(24,round((width-1)*0.30)))) self.tmux('resize-pane','-t',monitor['id'],'-x',str(monitor_width)) else: self.tmux('select-layout','-t',self.window,'even-vertical') current={p['id']:p for p in self.panes()} if current[monitor['id']]['top']>current[shell['id']]['top']: self.tmux('swap-pane','-d','-s',shell['id'],'-t',monitor['id']) self.tmux('resize-pane','-t',monitor['id'],'-y',str(max(2,min(8,(height-3)//3)))) if active: self.tmux('select-pane','-t',active) def apply(self): existed=self.exists() if not existed: parent=Path(self.a.socket).parent parent.mkdir(mode=0o700,parents=True,exist_ok=True) st=parent.stat() if st.st_uid!=os.getuid() or st.st_mode & 0o022: raise RuntimeError('Console socket directory is not private to this user') size=os.get_terminal_size(0) if os.isatty(0) else os.terminal_size((240,67)) self.session_target,self.window=self.tmux('new-session','-d','-P','-F','#{session_id}|#{window_id}', '-x',str(size.columns),'-y',str(size.lines), '-s',self.a.session,'-n','Console','-c',str(Path.home()),'/bin/bash --login').split('|') rows=self.panes() shell,monitor=self.locate(rows) if self.a.reflow: if shell and monitor and len(rows)==2: # Zoom is an explicit user choice; never undo it in a resize hook. if self.tmux('display-message','-p','-t',self.window,'#{window_zoomed_flag}')!='1': self.layout(shell,monitor) return if not shell or shell['dead']: raise RuntimeError('Normal Bash pane is absent; refusing to replace another program') if len(rows)>2 or (len(rows)==2 and not monitor): raise RuntimeError('Unexpected console panes; refusing to modify them') original_pid=shell['pid'] previous_focus=next((p['id'] for p in rows if p['active']),shell['id']) previous_zoom=self.tmux('display-message','-p','-t',self.window,'#{window_zoomed_flag}')=='1' if not monitor: ident=self.tmux('split-window','-d','-h','-l','30%','-t',shell['id'],'-P','-F','#{pane_id}',self.monitor_command()) monitor=next(p for p in self.panes() if p['id']==ident) else: try: cmd=Path('/proc',str(monitor['pid']),'cmdline').read_bytes() except OSError: cmd=b'' if self.a.refresh_monitor or monitor['dead'] or str(HERE/'framebridge_console.py').encode() not in cmd: self.tmux('respawn-pane','-k','-t',monitor['id'],self.monitor_command()) for role,pane in [('shell',shell),('monitor',monitor)]: self.tmux('set-option','-p','-t',pane['id'],'@framebridge_role',role) self.tmux('set-option','-p','-t',monitor['id'],'remain-on-exit','on') self.tmux('select-pane','-t',shell['id'],'-T','SHELL | '+self.a.node+' | '+getpass.getuser()) self.tmux('select-pane','-t',monitor['id'],'-T','FBTERM | ACTIVITY') self.tmux('set-window-option','-t',self.window,'automatic-rename','off') self.tmux('rename-window','-t',self.window,'Console') for key,value in [('pane-border-status','top'),('pane-border-style','fg=blue'), ('pane-active-border-style','fg=cyan'), ('pane-border-format',' #{?pane_active,>, } #{pane_title} '), ('window-status-format',''),('window-status-current-format','')]: self.tmux('set-window-option','-t',self.window,key,value) for key,value in [('status','on'),('status-style','bg=blue,fg=white'), ('status-left','#[bold] FRAMEBRIDGE #[default] | '+self.a.node+' | '), ('status-left-length','80'),('status-right-length','110'), ('status-right',' Ctrl+B, then o: switch | z: zoom | %H:%M %Z ')]: self.tmux('set-option','-t',self.session_target,key,value) self.tmux('set-option','-t',self.session_target,'mouse','off') # This server is dedicated to FrameBridge; do not alter global tmux configuration. for key in ('z','C-z','Z'): self.tmux('bind-key','-T','prefix',key,'resize-pane','-Z') # Reattachment must preserve the user's zoom and pane focus. if not previous_zoom: self.layout(shell,monitor) reflow=['/usr/bin/python3',str(HERE/'framebridge_console.py'),'--node',self.a.node, '--socket',self.a.socket,'--session',self.a.session, '--layout',getattr(self.a,'layout','auto'),'--reflow'] if getattr(self.a,'jobs_root',None) is not None: reflow.extend(['--jobs-root',str(self.a.jobs_root)]) command=shlex.join(reflow) self.tmux('set-hook','-t',self.session_target,'client-resized[90]','run-shell -b '+shlex.quote(command)) self.tmux('set-hook','-t',self.session_target,'client-attached[90]','run-shell -b '+shlex.quote(command)) self.tmux('select-window','-t',self.window) selected=previous_focus if existed else shell['id'] self.tmux('select-pane','-t',selected) if previous_zoom and self.tmux('display-message','-p','-t',self.window,'#{window_zoomed_flag}')!='1': self.tmux('resize-pane','-Z','-t',selected) current=next(p for p in self.panes() if p['id']==shell['id']) if current['pid']!=original_pid: raise RuntimeError('Bash identity changed unexpectedly') def supervise(node, jobs_root=None): command=['/usr/bin/python3','-u',str(HERE/'fbterm_dashboard.py'),'--node',node] if jobs_root is not None: command.extend(['--root',str(jobs_root)]) while True: try: result=subprocess.run(command) print('\r\nFBTERM monitor stopped (exit '+str(result.returncode)+'). Restarting in 2 seconds.',flush=True) print('Ctrl+B o switches to the shell.',flush=True) time.sleep(2) except KeyboardInterrupt: print('\r\nFBTERM monitor restarting. Ctrl+B o switches to the shell.',flush=True) time.sleep(1) def main(): args=arguments() if args.monitor: supervise(args.node, args.jobs_root) return 0 console=Console(args) if args.probe: print(json.dumps({'node':args.node,'socket':args.socket,'session':args.session, 'panes':console.panes() if console.exists() else [],'login':'existing-authenticated-login'})) return 0 if not (args.apply or args.reflow): if not os.isatty(0) or os.ttyname(0)!='/dev/tty1' or os.environ.get('TMUX') or os.environ.get('SSH_TTY'): print('Open this console from an authenticated tty1 login. --probe is read-only.',file=sys.stderr) return 1 parent=Path(args.socket).parent parent.mkdir(mode=0o700,parents=True,exist_ok=True) info=parent.stat() if info.st_uid!=os.getuid() or info.st_mode & 0o022: raise RuntimeError('Console socket directory is not private to this user') fd=os.open(parent/'console-layout.lock',os.O_CREAT|os.O_RDWR|os.O_CLOEXEC|os.O_NOFOLLOW,0o600) try: info=os.fstat(fd) if not stat.S_ISREG(info.st_mode) or info.st_uid!=os.getuid(): raise RuntimeError('Invalid console layout lock') fcntl.flock(fd,fcntl.LOCK_EX) console.apply() finally: os.close(fd) if args.apply: print(json.dumps({'node':args.node,'status':'READY','panes':console.panes()})) elif not args.reflow: os.execve('/usr/bin/tmux',['tmux','-S',args.socket,'attach-session','-t',console.session_target],console.env) return 0 if __name__=='__main__': try: raise SystemExit(main()) except (RuntimeError,OSError,subprocess.TimeoutExpired) as e: print('FrameBridge console: '+str(e),file=sys.stderr) raise SystemExit(1)