#!/usr/bin/env python3.14# -*- coding: utf-8 -*-"""The per-user aiohttp app: one collaborator's content **and** web shell.`build_app` folds two surfaces onto one application:- **content** — the whole site route table, reused verbatim from :func:`euler_site.site.app.install_content` (home, solutions, docs, blogs, account, the edit routes). Nothing about those handlers is per-profile; they read the request's :class:`~solver.auth.subject.Subject` and gate on it.- **shell** — `GET /ws` attaches the browser terminal to *this user's* persistent PTY shell (:mod:`euler_webshell.ws` machinery); `POST /internal/logout` is the auth service's teardown push and `GET /internal/status` the operator's shell report (both socket-peer only — Caddy answers `/internal/*` with 404 rather than routing it).The difference from the retired per-profile services is **identity**: this process*is* one user's uid (`EULER_USER_SLUG`), so the identity middleware refuses anyrequest whose `X-User` maps to a different slug — misrouting or a bypass — and itresolves the profile from the trusted `X-Profile` uncapped (an `admin` account isweb-reachable now). Caddy routes every request to the right user's socket by`X-User-Slug`, so in production the pin only ever agrees; the check is thecode-side backstop to that OS boundary."""from__future__importannotations__all__=['build_app','PTY_MANAGER']importasyncioimportjsonimportloggingimportpwdimportsocketfromtypingimportAny,Awaitable,Callablefromurllib.parseimporturlsplitfromaiohttpimportWSCloseCode,WSMsgType,webfromsolver.authimportAuthorizations,Subjectfromsolver.auth.identityimportsystem_slugfromsolver.cryptoimportvaultfromeuler_site.auth.clientimportrequestasauth_requestfromeuler_site.cacheimportcache_middlewarefromeuler_site.cspimportcsp_middlewarefromeuler_site.site.appimport_MAX_BODY,install_contentfromeuler_site.site.renderimportSUBJECT_KEYfromeuler_webshell.msg.identityimportpeer_uidfromeuler_webshell.user.configimportUserConfigfromeuler_webshell.user.logoutimportLogoutNoticefromeuler_webshell.user.msg_apiimportadd_message_routesfromeuler_webshell.user.vault_apiimportadd_vault_routesfromeuler_webshell.ws.managerimportPtyManagerfromeuler_webshell.ws.ptyimportPtySessionlog=logging.getLogger('euler-user')_Handler=Callable[[web.Request],Awaitable[web.StreamResponse]]#: The attach gate: the reader-floor "may run the solver at all" grant.ATTACH_REQUIRES:str='reader'#: Who may connect, by path: the account names whose uid the kernel must report for the#: peer (`SO_PEERCRED`). Caddy carries every browser request; the auth service pushes the#: teardown and the vault reset; the message spool pushes deliveries; root runs the operator's#: status sweep and may call anything. A path not listed takes the `*` entry. Socket#: permissions are the first gate (only `euler-edge` may connect); this is the second, and#: the one that keeps the identity headers and the `/internal/*` pushes trustworthy should#: the socket's group ever be widened.PEERS:dict[str,frozenset[str]]={'*':frozenset({'euler-caddy'}),'/internal/logout':frozenset({'euler-auth'}),'/internal/vault-reset':frozenset({'euler-auth'}),'/internal/message':frozenset({'euler-msg'}),'/internal/status':frozenset(),'/internal/logout-notice':frozenset(),}#: Bounds on what the logout notice takes: a day of waiting, and a reason that fits the banner._MAX_NOTICE_SECONDS:int=86400_MAX_REASON:int=200def_peer_allowed(uid:int,path:str)->bool:"""Whether the account behind *uid* may call *path* (root always may)."""ifuid==0:returnTruetry:name=pwd.getpwuid(uid).pw_nameexceptKeyError:returnFalsereturnnameinPEERS.get(path,PEERS['*'])#: 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_slug:str)->Subject|None:"""Build the request's Subject from the forward_auth headers, or None. Caddy guarantees `X-User`/`X-Profile` on every routed request and strips any client copy. A missing/unknown profile yields None. *pin_slug* is this instance's own user (`EULER_USER_SLUG`): a request whose `X-User` maps to a different :func:`system_slug` is refused (None) — Caddy routes each user to their own socket, so a mismatch means misrouting or a bypass. The profile is **not** capped: an `admin` account is web-reachable. """user=request.headers.get('X-User','').strip()profile=request.headers.get('X-Profile','').strip()ifnotuserornotprofileorprofilenotinauthz.known_profiles():returnNoneifpin_slugandsystem_slug(user)!=pin_slug:returnNonereturnSubject(user=user,slug=system_slug(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:UserConfig)->web.Application:"""Assemble the per-user app: content + `/ws` on one application."""authz=Authorizations.load()pin_slug=config.slugmanager=PtyManager()notice=LogoutNotice(manager,vault.clear_session_key)@web.middlewareasyncdefpeer_middleware(request:web.Request,handler:_Handler)->web.StreamResponse:"""Refuse a connecting process that is not one of :data:`PEERS` for this path. Production serves only the systemd-passed unix socket, where the kernel reports the peer. A transport with no peer credentials — the TCP bind of a dev run, the test client — has no uid to check and is let through; that bind is a developer setting. """sock=request.transport.get_extra_info('socket')ifrequest.transportelseNoneuid=peer_uid(sock)ifsockisnotNoneandsock.family==socket.AF_UNIXelseNoneifuidisnotNoneandnot_peer_allowed(uid,request.path):log.warning('refused peer uid %d on %s',uid,request.path)returnweb.Response(status=403,text='peer not allowed')returnawaithandler(request)@web.middlewareasyncdefidentity_middleware(request:web.Request,handler:_Handler)->web.StreamResponse:request[SUBJECT_KEY]=_subject_from_headers(request,authz,pin_slug)returnawaithandler(request)app=web.Application(middlewares=[peer_middleware,cache_middleware,csp_middleware,identity_middleware],client_max_size=_MAX_BODY)# The content surface (routes + jinja + static + /healthz) — reused verbatim.install_content(app,config.site_config(),authz)# The vault + account surface — this instance IS the user's uid.add_vault_routes(app)# The message surface — the browser half of the spool (web-server-guide § Messaging).# The manager goes in by argument: the app key below is defined here, and msg_api# importing it back would close a cycle.add_message_routes(app,manager)app[PTY_MANAGER]=managerasyncdef_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 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'])asyncdefwebsocket(request:web.Request)->web.StreamResponse:"""Attach a browser terminal to this user's persistent shell."""subject:Subject|None=request.get(SUBJECT_KEY)ifsubjectisNone:returnweb.Response(status=401,text='unauthenticated')ifnotsubject.has(ATTACH_REQUIRES):returnweb.Response(status=403,text='forbidden')# A browser sends Origin on every WebSocket upgrade. The session cookie is# SameSite=Lax, which keeps it off a cross-site page's upgrade — but not off one from# a sibling subdomain, so the terminal also checks the page it is attaching to is ours.origin=request.headers.get('Origin')iforiginisnotNoneandurlsplit(origin).netloc!=request.host:log.warning('attach refused for %s: foreign origin %s',subject.user,origin)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)# The child pins on this instance's slug (EULER_USER_SLUG); its profile comes# from redeeming the ticket, not from us.returnPtySession(ticket=ticket,argv=config.shell_argv,auth_socket=config.auth_socket,slug=config.slug)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 this 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)vault.clear_session_key()# the session ended — leave no reachable key materiallog.info('logout push for %s (shell %s)',email,'closed'ifclosedelse'absent')returnweb.json_response({'closed':closed})asyncdefinternal_logout_notice(request:web.Request)->web.Response:"""Arm, extend or end this instance's shutdown notice (:mod:`euler_webshell.user.logout`). Root only, like the status sweep: `{"reason", "seconds", "limit"}` arms it, `{"extend": n}` adds time, `{"now": true}` ends the wait. A GET reports where it stands. """ifrequest.method=='GET':returnweb.json_response(notice.status())try:body:Any=awaitrequest.json()exceptjson.JSONDecodeError:returnweb.Response(status=400,text='bad request')ifnotisinstance(body,dict):returnweb.Response(status=400,text='bad request')ifbody.get('now')isTrue:notice.now()returnweb.json_response(notice.status())numbers=[body.get(key)forkeyin(('extend',)if'extend'inbodyelse('seconds','limit'))]ifnotall(type(number)isintand0<=number<=_MAX_NOTICE_SECONDSfornumberinnumbers):returnweb.Response(status=400,text='bad request')if'extend'inbody:notice.extend(body['extend'])else:notice.arm(str(body.get('reason',''))[:_MAX_REASON],body['seconds'],body['limit'])log.info('logout notice armed: %ds, limit %ds',body['seconds'],body['limit'])returnweb.json_response(notice.status())asyncdefinternal_status(request:web.Request)->web.Response:"""Report this instance's shell state for the operator's `status-web` sweep. Socket-peer only, like the logout push: Caddy answers `/internal/*` itself (404) rather than routing it, so the only callers are root and the `euler-web` tier over this user's own socket. Read-only — it never touches a live shell. """manager_=request.app[PTY_MANAGER]returnweb.json_response({'slug':pin_slug,'shells':manager_.snapshot()})asyncdef_reaper()->None:"""Reap shells detached longer than the TTL (hygiene, not security). Never dies."""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:passasyncdef_close_all(app_:web.Application)->None:awaitnotice.cancel()awaitapp_[PTY_MANAGER].close_all()vault.clear_session_key()# service stop: drop the tmpfs key file tooapp.add_routes([web.get('/ws',websocket),web.post('/internal/logout',internal_logout),web.get('/internal/status',internal_status),web.get('/internal/logout-notice',internal_logout_notice),web.post('/internal/logout-notice',internal_logout_notice),])app.on_startup.append(_on_startup)app.on_cleanup.append(_cancel_reaper)app.on_cleanup.append(_close_all)returnapp