#!/usr/bin/env python3.14# -*- coding: utf-8 -*-"""The web-shell aiohttp app: identity from forward_auth, the /ws attach, teardown.Three routes:- `GET /healthz` — the kit's socket probe.- `GET /ws` — attach the browser terminal to the caller's persistent shell. Identity is the trusted `X-User`/`X-Profile` pair (Caddy strips client-supplied copies and stamps the `forward_auth` response); the instance refuses a profile that differs from its `EULER_PROFILE` pin and gates attach on the **reader floor**. On fork it forwards the caller's session cookie to the auth service's `POST /shell-ticket` and passes only the minted single-use ticket to the child.- `POST /internal/logout` — the auth service's teardown push: `{"email": …}` closes that user's shell. Socket-peer only — Caddy routes `/ws` and nothing else to this service, so no browser can reach it.Wire protocol on `/ws`: binary frames are raw PTY bytes both ways; a textframe `{"resize": [cols, rows]}` propagates the browser geometry."""from__future__importannotations__all__=['build_app']importasyncioimportjsonimportloggingfromtypingimportAnyfromaiohttpimportWSCloseCode,WSMsgType,webfromsolver.auth.authorizationsimportAuthorizationsfromsolver.auth.identityimportslugifyfromsolver.auth.subjectimportSubjectfromeuler_site.auth.clientimportrequestasauth_requestfromeuler_site.cacheimportcache_middlewarefromeuler_webshell.ws.configimportWsConfigfromeuler_webshell.ws.managerimportPtyManagerfromeuler_webshell.ws.ptyimportPtySessionlog=logging.getLogger('euler-ws')#: The attach gate: the reader-floor "may run the solver at all" grant.ATTACH_REQUIRES:str='reader'#: Per-app PtyManager, for tests and the lifecycle hooks.PTY_MANAGER:web.AppKey[PtyManager]=web.AppKey('pty_manager',PtyManager)#: The detached-TTL reaper task, stored so cleanup can cancel it._REAPER_TASK:web.AppKey[asyncio.Task[None]]=web.AppKey('reaper_task',asyncio.Task)def_subject_from_headers(request:web.Request,authz:Authorizations,pin:str)->Subject|None:"""Build the request's Subject from the forward_auth headers, or None. Caddy guarantees these headers on every routed request (and strips any client-supplied copies). A missing/unknown profile yields None; so does a profile differing from this instance's *pin* — Caddy routes each profile to its own per-profile uid's socket, so a mismatch means misrouting or a bypass (the code-side backstop to the OS boundary). """user=request.headers.get('X-User','').strip()profile=request.headers.get('X-Profile','').strip()ifnotuserornotprofileorprofilenotinauthz.known_profiles():returnNoneifpinandprofile!=pin:returnNonereturnSubject(user=user,slug=slugify(user),channel='web',auth_method='forward-auth',profile=profile)def_parse_resize(raw:str)->tuple[int,int]|None:"""Parse a `{"resize": [cols, rows]}` control frame; None if it is not one."""try:message=json.loads(raw)exceptjson.JSONDecodeError:returnNoneifnotisinstance(message,dict):returnNonesize=message.get('resize')if(isinstance(size,list)andlen(size)==2andall(isinstance(v,int)and0<v<1000forvinsize)):returnsize[0],size[1]returnNonedefbuild_app(config:WsConfig)->web.Application:"""Assemble the web-shell app for one per-profile instance."""authz=Authorizations.load()manager=PtyManager()asyncdef_mint_ticket(cookie:str)->str:"""Mint a one-time shell ticket against the caller's live session. The auth client is blocking stdlib; run it off-loop. Any refusal — session died between forward_auth and here, service down — aborts the fork: an unvouched shell must not start. """loop=asyncio.get_running_loop()try:status,data=awaitloop.run_in_executor(None,lambda:auth_request(config.auth_socket,'POST','/shell-ticket',headers={'Cookie':cookie}))exceptOSErrorasexc:raisePermissionError(f'auth service unreachable ({exc})')fromNoneifstatus!=200ornotisinstance(data,dict)ornotdata.get('ticket'):raisePermissionError('shell ticket refused')returnstr(data['ticket'])asyncdefhealthz(request:web.Request)->web.Response:returnweb.Response(text='ok')asyncdefwebsocket(request:web.Request)->web.StreamResponse:"""Attach a browser terminal to the signed-in user's persistent shell. The shell is forked on first attach (minting the ticket then), replayed on reattach; binary frames are keystrokes, a `resize` text frame drives the shared PTY geometry. Detach leaves the shell running. """subject=_subject_from_headers(request,authz,config.profile)ifsubjectisNone:returnweb.Response(status=401,text='unauthenticated')ifnotsubject.has(ATTACH_REQUIRES):returnweb.Response(status=403,text='forbidden')ws=web.WebSocketResponse(heartbeat=30.0)awaitws.prepare(request)cookie=request.headers.get('Cookie','')asyncdefspawn()->PtySession:ticket=await_mint_ticket(cookie)returnPtySession(ticket=ticket,profile=config.profileorsubject.profile,argv=config.shell_argv,auth_socket=config.auth_socket)try:pty=awaitmanager.get_or_create(subject.user,spawn)exceptPermissionErrorasexc:log.warning('attach refused for %s: %s',subject.user,exc)awaitws.close(code=WSCloseCode.POLICY_VIOLATION,message=str(exc).encode())returnwslog.info('%s attached (profile %s)',subject.user,subject.profile)awaitpty.attach(ws)try:asyncformsginws:ifmsg.type==WSMsgType.BINARY:pty.write(msg.data)elifmsg.type==WSMsgType.TEXT:size=_parse_resize(msg.data)ifsizeisnotNone:pty.resize(cols=size[0],rows=size[1])elifmsg.type==WSMsgType.ERROR:breakfinally:pty.detach(ws)# the shell keeps running; only this socket goes awaylog.info('%s detached',subject.user)returnwsasyncdefinternal_logout(request:web.Request)->web.Response:"""Tear down a user's shell on the auth service's push. Reachable only by socket peers (`euler-web` members) — Caddy never routes here. Idempotent: closing an absent shell reports closed=False. """try:body:Any=awaitrequest.json()exceptjson.JSONDecodeError:returnweb.Response(status=400,text='bad request')email=str(body.get('email','')).strip()ifisinstance(body,dict)else''ifnotemail:returnweb.Response(status=400,text='bad request')closed=awaitmanager.close(email)log.info('logout push for %s (shell %s)',email,'closed'ifclosedelse'absent')returnweb.json_response({'closed':closed})asyncdef_close_all(app:web.Application)->None:awaitapp[PTY_MANAGER].close_all()asyncdef_reaper()->None:"""Periodically reap shells detached longer than the TTL (hygiene, not security). The cadence tracks the TTL (a short test TTL is checked often; the 24 h production default every 60 s), so a shell is reaped within one interval of crossing it. A reaper failure must not take the loop down. """ttl=config.detached_ttlinterval=max(1,min(ttl,60))whileTrue:awaitasyncio.sleep(interval)try:foremailinawaitmanager.reap_detached(ttl):log.info('reaped detached shell for %s (idle > %ds)',email,ttl)exceptException:# noqa: BLE001 — the reaper must keep runninglog.exception('reaper pass failed')asyncdef_on_startup(app:web.Application)->None:ifconfig.detached_ttl>0:app[_REAPER_TASK]=asyncio.create_task(_reaper())asyncdef_cancel_reaper(app:web.Application)->None:task=app.get(_REAPER_TASK)iftaskisnotNone:task.cancel()try:awaittaskexceptasyncio.CancelledError:passapp=web.Application(middlewares=[cache_middleware])app[PTY_MANAGER]=managerapp.add_routes([web.get('/healthz',healthz),web.get('/ws',websocket),web.post('/internal/logout',internal_logout),])app.on_startup.append(_on_startup)app.on_cleanup.append(_cancel_reaper)app.on_cleanup.append(_close_all)returnapp