#!/usr/bin/env python3.14# -*- coding: utf-8 -*-"""The message service: public + admin aiohttp apps over unix sockets.Two listeners, one process, one owner of the spool:- **Public** (`/run/euler/msg.sock`, group `euler-web`) — reached by the per-user services and their PTY children, never by Caddy. Identity is `SO_PEERCRED`: the connecting uid *is* the caller (:mod:`euler_webshell.msg.identity`), so nothing on this wire carries a sender field and there is no bearer token to leak.- **Admin** (`/run/euler-adm/msg-admin.sock`, `0600` euler-msg-private) — the operator's terminal path, never routed by Caddy and **wheel-gated**: only root (via sudo) can connect, and `X-Admin-Token` (kept solely in root-readable `/etc/euler/msg.env`) is a second check. It exists because the operator's own uid is deliberately **not** in `euler-web` and so cannot dial the public socket at all.Authorization on both planes is the plain ladder, resolved **per request** against`authorizations.json` — a demote lands within one request rather than at next login.Delivery is a best-effort nudge to the recipient's own instance socket(`/internal/message`), the same socket-peer-only push the auth service uses forlogout teardown. The spool is the system of record: a lost nudge costs a stale badgeuntil the next navigation, never a message."""from__future__importannotations__all__=['MessageService','build_admin_app','build_app']importasyncioimporthmacimportloggingfrompathlibimportPathfromtypingimportAny,Awaitable,Callableimportaiohttpfromaiohttpimportwebfromsolver.auth.subjectimportrankfromeuler_webshell.msg.configimportMsgConfigfromeuler_webshell.msg.identityimportSTAFF_FLOOR,PolicyView,box_of,peer_uidfromeuler_webshell.msg.storeimportMessageStore,Threadlog=logging.getLogger('euler-msg')_Handler=Callable[[web.Request],Awaitable[web.StreamResponse]]#: Request key under which the peer middleware stores `(box, identity, profile)`.CALLER_KEY:str='caller'#: The floor every read/write of one's *own* mail sits at — the terminal is the front#: door for every rung, and a new invitee's first need is often to ask a question.OWN_FLOOR:str='reader'#: Broadcast target meaning "every box the policy maps".EVERYONE:str='*'asyncdef_json_body(request:web.Request)->dict[str,Any]:"""The request's JSON object body; an empty dict for anything malformed."""try:data=awaitrequest.json()exceptException:# noqa: BLE001 — any parse failure is "no body"return{}returndataifisinstance(data,dict)else{}classMessageService:"""The spool, the policy view, and the delivery nudge; handlers are thin over this."""def__init__(self,config:MsgConfig)->None:self.config=configself.store=MessageStore(config.state_dir/'messages.json')self.policy=PolicyView()# ── views ──────────────────────────────────────────────────────────────────────defis_staff(self,profile:str)->bool:"""Whether *profile* is at or above the staff floor."""returnrank(profile)>=rank(STAFF_FLOOR)defrender_thread(self,thread:Thread)->dict[str,Any]:"""One thread with display identities resolved through the policy view."""returnthread.summary(author_name=self.policy.identity_of(thread.author))defmailbox(self,box:str,profile:str,*,since:str='')->dict[str,Any]:"""The caller's own view: their threads (newest first) and their unread count."""staff=self.is_staff(profile)threads=self.store.threads_for(box,staff=staff)ifsince:threads=[threadforthreadinthreadsifthread.updated>since]return{'threads':[self.render_thread(thread)forthreadinthreads],'unread':self.store.unread_count(box,staff=staff),'staff':staff}# ── writes ─────────────────────────────────────────────────────────────────────asyncdefsubmit(self,box:str,subject:str,body:str)->str|None:"""Queue a user→staff question and nudge every staff box that has an instance."""staff=self.policy.staff_boxes()ifnotstaff:log.warning('no staff mapped — refusing to queue a message with no reader')returnNonethread_id=self.store.submit(box,subject,body,staff)ifthread_idisnotNone:awaitself.notify([targetfortargetinstaffiftarget!=box])returnthread_idasyncdefnotice(self,box:str,subject:str,body:str,targets:list[str])->tuple[str|None,list[str]]:"""Send a staff notice; return `(thread id, resolved recipient boxes)`. A target the policy does not map is dropped from the set and reported back, so a typo is visible to the sender rather than filed into a box nobody can read. """iftargets==[EVERYONE]:boxes=[targetfortargetinself.policy.all_boxes()iftarget!=box]else:boxes=[resolvedforresolvedin(self.policy.box_for_identity(target)fortargetintargets)ifresolvedisnotNone]ifnotboxes:returnNone,[]thread_id=self.store.notice(box,subject,body,boxes)ifthread_idisnotNone:awaitself.notify(boxes)returnthread_id,boxesasyncdefnotify(self,boxes:list[str])->None:"""Nudge each box's own instance so an attached terminal updates its badge. Only web identities have an instance to push to; a local os-login (the operator at a terminal) has none and simply sees the count when they next run `msg`. Best-effort throughout — the spool is the system of record. """base=self.config.user_socket_dirifnotbase:returnforboxinboxes:ifnotself.policy.is_web(box):continueawaitself._push(box,self.store.unread_count(box,staff=False))asyncdef_push(self,box:str,unread:int)->None:"""POST `/internal/message` to *box*'s instance socket; never raises."""sock=Path(self.config.user_socket_dir)/f'user-{box}.sock'try:connector=aiohttp.UnixConnector(path=str(sock))timeout=aiohttp.ClientTimeout(total=3)asyncwithaiohttp.ClientSession(connector=connector,timeout=timeout)ashttp:asyncwithhttp.post('http://user/internal/message',json={'slug':box,'unread':unread})asresp:awaitresp.read()except(OSError,aiohttp.ClientError,asyncio.TimeoutError):pass# absent / down / slow — the instance isn't there# ── public app (msg.sock — socket peers only, never routed by Caddy) ────────────────defbuild_app(service:MessageService)->web.Application:"""The public listener: one collaborator's mail, and the staff verbs for staff."""@web.middlewareasyncdefpeer_identity(request:web.Request,handler:_Handler)->web.StreamResponse:"""Resolve the connecting uid to `(box, identity, profile)`, or refuse. The kernel is the identity here (web-server-guide § Messaging): a peer whose uid maps to no policy entry gets 401, so an un-mapped service account on the `euler-web` group cannot read or write anyone's mail. """ifrequest.path=='/healthz':returnawaithandler(request)sock=request.transport.get_extra_info('socket')ifrequest.transportelseNoneuid=peer_uid(sock)ifsockisnotNoneelseNonecaller=service.policy.resolve_uid(uid)ifuidisnotNoneelseNoneifcallerisNone:returnweb.Response(status=401,text='unidentified peer')request[CALLER_KEY]=callerreturnawaithandler(request)defcaller(request:web.Request)->tuple[str,str,str]:"""`(box, identity, profile)` for this request (the middleware guarantees it)."""resolved:tuple[str,str,str]=request[CALLER_KEY]returnresolveddefrefuse_below(request:web.Request,floor:str)->web.Response|None:"""A 403 when the caller is below *floor*, else None."""_box,identity,profile=caller(request)ifrank(profile)<rank(floor):log.info('refused %s (%s) below %s on %s',identity,profile,floor,request.path)returnweb.Response(status=403,text=f'requires {floor}')returnNoneasyncdefhealthz(_request:web.Request)->web.Response:returnweb.Response(text='ok')asyncdefmailbox(request:web.Request)->web.Response:"""`GET /messages` — this caller's threads and unread count."""box,_identity,profile=caller(request)since=request.query.get('since','').strip()returnweb.json_response(service.mailbox(box,profile,since=since))asyncdefone_thread(request:web.Request)->web.Response:"""`GET /messages/{id}` — one thread, if it is the caller's to read."""box,_identity,profile=caller(request)thread=service.store.thread(request.match_info['id'],box,staff=service.is_staff(profile))ifthreadisNone:returnweb.Response(status=404,text='no such thread')returnweb.json_response(service.render_thread(thread))asyncdefsubmit(request:web.Request)->web.Response:"""`POST /messages` `{subject, body}` — ask staff something."""box,identity,_profile=caller(request)body=await_json_body(request)thread_id=awaitservice.submit(box,str(body.get('subject','')),str(body.get('body','')))ifthread_idisNone:returnweb.Response(status=400,text='message refused (empty, too long, or over quota)')log.info('message queued by %s (%s)',identity,thread_id)returnweb.json_response({'id':thread_id},status=201)asyncdefmark_read(request:web.Request)->web.Response:"""`POST /messages/{id}/read` — mark a thread read by this caller."""box,_identity,profile=caller(request)thread_id=request.match_info['id']ifnotservice.store.mark_read(thread_id,box,staff=service.is_staff(profile)):returnweb.Response(status=404,text='no such thread')returnweb.json_response({'id':thread_id,'read':True})asyncdefqueue(request:web.Request)->web.Response:"""`GET /staff/queue` — the inbound queue as a work list (staff only)."""refused=refuse_below(request,STAFF_FLOOR)ifrefusedisnotNone:returnrefusedbox,_identity,_profile=caller(request)returnweb.json_response({'queue':[service.render_thread(t)fortinservice.store.inbound(box)]})asyncdefnotice(request:web.Request)->web.Response:"""`POST /staff/notice` `{to, subject, body}` — named recipients or `*`."""refused=refuse_below(request,STAFF_FLOOR)ifrefusedisnotNone:returnrefusedbox,identity,_profile=caller(request)body=await_json_body(request)raw_to=body.get('to')targets=[str(raw_to)]ifisinstance(raw_to,str)else[str(t)fortin(raw_toor[])]thread_id,boxes=awaitservice.notice(box,str(body.get('subject','')),str(body.get('body','')),targets)ifthread_idisNone:returnweb.Response(status=400,text='notice refused (no known recipient, or bad fields)')log.info('%s sent a notice to %d box(es) (%s)',identity,len(boxes),thread_id)returnweb.json_response({'id':thread_id,'recipients':len(boxes)},status=201)asyncdefdismiss(request:web.Request)->web.Response:"""`DELETE /staff/queue/{id}` — drop a worked message. Staff dismiss anything (the queue is theirs to clear). Anyone else may dismiss a message **they are a party to**, which is what lets an act clean up after itself: `msg act` takes the key and the message it came in is spent. Without that, the one rung that most needs the tidying — a reader, who cannot reach the staff queue — would be the one left with a mailbox of worked messages. """box,_identity,profile=caller(request)thread_id=request.match_info['id']ifnotservice.is_staff(profile)andservice.store.thread(thread_id,box)isNone:returnweb.Response(status=403,text='not yours to dismiss')ifnotservice.store.drop(thread_id):returnweb.Response(status=404,text='no such message')log.info('message %s dismissed',thread_id)returnweb.json_response({'id':thread_id,'dismissed':True})app=web.Application(middlewares=[peer_identity])app.add_routes([web.get('/healthz',healthz),web.get('/messages',mailbox),web.post('/messages',submit),web.get('/messages/{id}',one_thread),web.post('/messages/{id}/read',mark_read),web.get('/staff/queue',queue),web.delete('/staff/queue/{id}',dismiss),web.post('/staff/notice',notice),])returnapp# ── admin app (msg-admin.sock — root via sudo, never via Caddy) ─────────────────────defbuild_admin_app(service:MessageService)->web.Application:"""The operator's terminal plane: the same verbs, with the identity asserted by root. Root could bypass any check here, so the token and the profile validation are not containment against root — they keep the *ladder* the boundary (a typo cannot file a message under a stranger's name) and stop any non-root uid that somehow reached the socket. """@web.middlewareasyncdefrequire_token(request:web.Request,handler:_Handler)->web.StreamResponse:ifrequest.path!='/healthz':token=request.headers.get('X-Admin-Token','')ifnothmac.compare_digest(token,service.config.admin_token):returnweb.Response(status=401,text='bad admin token')returnawaithandler(request)defresolve(request:web.Request,body:dict[str,Any]|None=None)->tuple[str,str]|None:"""`(box, profile)` for the asserted identity, or None when unmapped. The identity may be a web address or an os-login — :func:`box_of` collapses both to the box the store routes by. """raw=str((bodyor{}).get('identity','')orrequest.query.get('identity','')).strip()found=service.policy.resolve(raw)ifrawelseNonereturnNoneiffoundisNoneelse(box_of(found[0]),found[1])asyncdefhealthz(_request:web.Request)->web.Response:returnweb.Response(text='ok')asyncdefmailbox(request:web.Request)->web.Response:who=resolve(request)ifwhoisNone:returnweb.Response(status=400,text='identity required (and must be in the policy)')returnweb.json_response(service.mailbox(who[0],who[1]))asyncdefone_thread(request:web.Request)->web.Response:who=resolve(request)ifwhoisNone:returnweb.Response(status=400,text='identity required')thread=service.store.thread(request.match_info['id'],who[0],staff=service.is_staff(who[1]))ifthreadisNone:returnweb.Response(status=404,text='no such thread')returnweb.json_response(service.render_thread(thread))asyncdefsubmit(request:web.Request)->web.Response:body=await_json_body(request)who=resolve(request,body)ifwhoisNone:returnweb.Response(status=400,text='identity required')thread_id=awaitservice.submit(who[0],str(body.get('subject','')),str(body.get('body','')))ifthread_idisNone:returnweb.Response(status=400,text='message refused (empty, too long, or over quota)')returnweb.json_response({'id':thread_id},status=201)asyncdefmark_read(request:web.Request)->web.Response:body=await_json_body(request)who=resolve(request,body)ifwhoisNone:returnweb.Response(status=400,text='identity required')thread_id=request.match_info['id']ifnotservice.store.mark_read(thread_id,who[0],staff=service.is_staff(who[1])):returnweb.Response(status=404,text='no such thread')returnweb.json_response({'id':thread_id,'read':True})asyncdefqueue(request:web.Request)->web.Response:who=resolve(request)ifwhoisNone:returnweb.Response(status=400,text='identity required')ifnotservice.is_staff(who[1]):returnweb.Response(status=403,text=f'requires {STAFF_FLOOR}')returnweb.json_response({'queue':[service.render_thread(t)fortinservice.store.inbound(who[0])]})asyncdefnotice(request:web.Request)->web.Response:body=await_json_body(request)who=resolve(request,body)ifwhoisNone:returnweb.Response(status=400,text='identity required')ifnotservice.is_staff(who[1]):returnweb.Response(status=403,text=f'requires {STAFF_FLOOR}')raw_to=body.get('to')targets=[str(raw_to)]ifisinstance(raw_to,str)else[str(t)fortin(raw_toor[])]thread_id,boxes=awaitservice.notice(who[0],str(body.get('subject','')),str(body.get('body','')),targets)ifthread_idisNone:returnweb.Response(status=400,text='notice refused (no known recipient, or bad fields)')returnweb.json_response({'id':thread_id,'recipients':len(boxes)},status=201)asyncdefdismiss(request:web.Request)->web.Response:body=await_json_body(request)who=resolve(request,body)ifwhoisNone:returnweb.Response(status=400,text='identity required')thread_id=request.match_info['id']ifnotservice.is_staff(who[1])andservice.store.thread(thread_id,who[0])isNone:returnweb.Response(status=403,text='not yours to dismiss')ifnotservice.store.drop(thread_id):returnweb.Response(status=404,text='no such message')returnweb.json_response({'id':thread_id,'dismissed':True})app=web.Application(middlewares=[require_token])app.add_routes([web.get('/healthz',healthz),web.get('/admin/messages',mailbox),web.post('/admin/messages',submit),web.get('/admin/threads/{id}',one_thread),web.post('/admin/threads/{id}/read',mark_read),web.get('/admin/queue',queue),web.post('/admin/notice',notice),web.delete('/admin/threads/{id}',dismiss),])returnapp