Run terminals and GUI apps as the authenticated user, not root
Atlus runs as root (systemd) but user-facing processes must run under the authenticated user's identity. Added privilege-dropping via preexec_fn (os.setgid + os.initgroups + os.setuid) to both terminal PTY spawning and GUI app launching. System admin operations (services, packages, network, updates) intentionally remain root. Autostart apps now support a configurable default_user; without one set, autostart defers until the first user logs in and runs as that user. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e44ee2fe64
commit
6a0c8757f8
6 changed files with 95 additions and 15 deletions
|
|
@ -64,6 +64,7 @@ _DEFAULT_CONFIG: dict = {
|
||||||
],
|
],
|
||||||
"panel_services": [], # systemd unit names to show in panel
|
"panel_services": [], # systemd unit names to show in panel
|
||||||
"gui_apps": [], # GUI apps: [{id, name, command, icon, args, target_fps}]
|
"gui_apps": [], # GUI apps: [{id, name, command, icon, args, target_fps}]
|
||||||
|
"default_user": None, # OS user for autostart apps (None = defer until first login)
|
||||||
"asi_bridge": {
|
"asi_bridge": {
|
||||||
"cifs_share": "//192.168.10.120/share",
|
"cifs_share": "//192.168.10.120/share",
|
||||||
"mount_point": "/mnt/asiair",
|
"mount_point": "/mnt/asiair",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ Each GUI app window is captured independently and streamed as JPEG frames.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import getpass
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
@ -17,6 +16,8 @@ import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from backend.privdrop import get_user_info, make_preexec_fn
|
||||||
|
|
||||||
log = logging.getLogger("atlus.display")
|
log = logging.getLogger("atlus.display")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -477,10 +478,18 @@ class DisplayManager:
|
||||||
|
|
||||||
display_num = await self.get_or_create_display(username)
|
display_num = await self.get_or_create_display(username)
|
||||||
|
|
||||||
|
# Resolve the user's home directory (not root's)
|
||||||
|
try:
|
||||||
|
_uid, _gid, user_home, _shell = get_user_info(username)
|
||||||
|
except KeyError:
|
||||||
|
user_home = os.path.expanduser("~")
|
||||||
|
|
||||||
env = {
|
env = {
|
||||||
**os.environ,
|
**os.environ,
|
||||||
"DISPLAY": f":{display_num}",
|
"DISPLAY": f":{display_num}",
|
||||||
"HOME": os.path.expanduser("~"),
|
"HOME": user_home,
|
||||||
|
"USER": username,
|
||||||
|
"LOGNAME": username,
|
||||||
}
|
}
|
||||||
# Ensure PATH
|
# Ensure PATH
|
||||||
path = env.get("PATH", "")
|
path = env.get("PATH", "")
|
||||||
|
|
@ -492,11 +501,20 @@ class DisplayManager:
|
||||||
cmd = [cmd_bin] + (args or [])
|
cmd = [cmd_bin] + (args or [])
|
||||||
log.info("Launching GUI app: %s (display :%d, user %s)", " ".join(cmd), display_num, username)
|
log.info("Launching GUI app: %s (display :%d, user %s)", " ".join(cmd), display_num, username)
|
||||||
|
|
||||||
|
# Drop privileges to the authenticated user
|
||||||
|
try:
|
||||||
|
preexec = make_preexec_fn(username)
|
||||||
|
except KeyError:
|
||||||
|
preexec = None
|
||||||
|
log.warning("User %s not found on system — app will run as service user", username)
|
||||||
|
|
||||||
process = await asyncio.create_subprocess_exec(
|
process = await asyncio.create_subprocess_exec(
|
||||||
*cmd,
|
*cmd,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
env=env,
|
env=env,
|
||||||
|
cwd=user_home,
|
||||||
|
preexec_fn=preexec,
|
||||||
)
|
)
|
||||||
|
|
||||||
app_id = str(uuid.uuid4())[:8]
|
app_id = str(uuid.uuid4())[:8]
|
||||||
|
|
@ -542,21 +560,29 @@ class DisplayManager:
|
||||||
app.kill()
|
app.kill()
|
||||||
return [a.to_dict() for a in user_apps.values()]
|
return [a.to_dict() for a in user_apps.values()]
|
||||||
|
|
||||||
async def autostart_apps(self, gui_apps: list[dict]):
|
async def autostart_apps(self, gui_apps: list[dict], default_user: str | None = None):
|
||||||
"""Launch all apps with autostart=True. Called on service startup."""
|
"""Launch all apps with autostart=True.
|
||||||
|
|
||||||
|
If *default_user* is set, apps start immediately as that user.
|
||||||
|
Otherwise autostart is deferred until the first user logs in.
|
||||||
|
"""
|
||||||
if not HAS_DISPLAY_DEPS:
|
if not HAS_DISPLAY_DEPS:
|
||||||
log.info("Display deps not available, skipping autostart")
|
log.info("Display deps not available, skipping autostart")
|
||||||
return
|
return
|
||||||
|
|
||||||
username = getpass.getuser()
|
autostart_list = [a for a in gui_apps if a.get("autostart") and a.get("command")]
|
||||||
|
if not autostart_list:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not default_user:
|
||||||
|
log.info("No default_user configured — deferring autostart until first login")
|
||||||
|
self._pending_autostart = autostart_list
|
||||||
|
return
|
||||||
|
|
||||||
|
username = default_user
|
||||||
started = 0
|
started = 0
|
||||||
for app_cfg in gui_apps:
|
for app_cfg in autostart_list:
|
||||||
if not app_cfg.get("autostart"):
|
command = app_cfg["command"]
|
||||||
continue
|
|
||||||
command = app_cfg.get("command", "")
|
|
||||||
if not command:
|
|
||||||
continue
|
|
||||||
# Skip if already running
|
|
||||||
if self.get_app_by_command(username, command):
|
if self.get_app_by_command(username, command):
|
||||||
log.debug("Autostart skip (already running): %s", command)
|
log.debug("Autostart skip (already running): %s", command)
|
||||||
continue
|
continue
|
||||||
|
|
@ -569,11 +595,20 @@ class DisplayManager:
|
||||||
target_fps=app_cfg.get("target_fps", 10),
|
target_fps=app_cfg.get("target_fps", 10),
|
||||||
)
|
)
|
||||||
started += 1
|
started += 1
|
||||||
log.info("Autostarted: %s", command)
|
log.info("Autostarted: %s (as %s)", command, username)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning("Failed to autostart %s: %s", command, e)
|
log.warning("Failed to autostart %s: %s", command, e)
|
||||||
if started:
|
if started:
|
||||||
log.info("Autostarted %d application(s)", started)
|
log.info("Autostarted %d application(s) as %s", started, username)
|
||||||
|
|
||||||
|
async def trigger_deferred_autostart(self, username: str):
|
||||||
|
"""Launch deferred autostart apps on first user login."""
|
||||||
|
pending = getattr(self, "_pending_autostart", None)
|
||||||
|
if not pending:
|
||||||
|
return
|
||||||
|
self._pending_autostart = None
|
||||||
|
log.info("Triggering deferred autostart for %s", username)
|
||||||
|
await self.autostart_apps(pending, default_user=username)
|
||||||
|
|
||||||
async def shutdown_all(self):
|
async def shutdown_all(self):
|
||||||
"""Kill all apps and Xvfb displays."""
|
"""Kill all apps and Xvfb displays."""
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,10 @@ async def lifespan(app: FastAPI):
|
||||||
# Autostart configured GUI apps (always-on desktop session)
|
# Autostart configured GUI apps (always-on desktop session)
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
gui_apps = cfg.get("gui_apps", [])
|
gui_apps = cfg.get("gui_apps", [])
|
||||||
|
default_user = cfg.get("default_user")
|
||||||
if gui_apps:
|
if gui_apps:
|
||||||
try:
|
try:
|
||||||
await display_manager.autostart_apps(gui_apps)
|
await display_manager.autostart_apps(gui_apps, default_user=default_user)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning("Autostart failed: %s", e)
|
log.warning("Autostart failed: %s", e)
|
||||||
|
|
||||||
|
|
|
||||||
37
backend/privdrop.py
Normal file
37
backend/privdrop.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""Privilege-dropping helpers for spawning processes as non-root users.
|
||||||
|
|
||||||
|
Atlus runs as root (systemd service) but user-facing processes — terminal
|
||||||
|
PTYs and GUI applications — must run as the authenticated user. This
|
||||||
|
module provides a ``preexec_fn`` factory that drops privileges after
|
||||||
|
``fork()`` but before ``exec()``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pwd
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_info(username: str) -> tuple[int, int, str, str]:
|
||||||
|
"""Return (uid, gid, home_dir, shell) for *username*.
|
||||||
|
|
||||||
|
Raises ``KeyError`` if the user does not exist on the system.
|
||||||
|
"""
|
||||||
|
pw = pwd.getpwnam(username)
|
||||||
|
return pw.pw_uid, pw.pw_gid, pw.pw_dir, pw.pw_shell
|
||||||
|
|
||||||
|
|
||||||
|
def make_preexec_fn(username: str) -> Callable[[], None]:
|
||||||
|
"""Return a closure suitable for ``preexec_fn`` that drops to *username*.
|
||||||
|
|
||||||
|
The user lookup (``pwd.getpwnam``) happens eagerly in the **parent**
|
||||||
|
process so that errors surface immediately. The ``os.set*`` calls
|
||||||
|
execute in the **child** process after ``fork()``.
|
||||||
|
"""
|
||||||
|
uid, gid, home, _shell = get_user_info(username)
|
||||||
|
|
||||||
|
def _drop_privileges() -> None:
|
||||||
|
os.setgid(gid)
|
||||||
|
os.initgroups(username, gid) # supplementary groups (sudo, video …)
|
||||||
|
os.setuid(uid) # must be last — can't setgid after this
|
||||||
|
|
||||||
|
return _drop_privileges
|
||||||
|
|
@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from backend.auth import get_current_user, decode_token
|
from backend.auth import get_current_user, decode_token
|
||||||
|
from backend.display import display_manager
|
||||||
from backend.sessions import manager
|
from backend.sessions import manager
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/session", tags=["session"])
|
router = APIRouter(prefix="/api/session", tags=["session"])
|
||||||
|
|
@ -35,6 +36,8 @@ class TerminalCreate(BaseModel):
|
||||||
async def get_session(user: str = Depends(get_current_user)):
|
async def get_session(user: str = Depends(get_current_user)):
|
||||||
"""Get or create the user's desktop session."""
|
"""Get or create the user's desktop session."""
|
||||||
session = manager.get_or_create(user)
|
session = manager.get_or_create(user)
|
||||||
|
# Trigger deferred autostart apps on first login
|
||||||
|
await display_manager.trigger_deferred_autostart(user)
|
||||||
return session.to_dict()
|
return session.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ import signal
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
|
||||||
|
from backend.privdrop import make_preexec_fn
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
@ -245,6 +247,7 @@ class SessionManager:
|
||||||
dimensions=(rows, cols),
|
dimensions=(rows, cols),
|
||||||
env=env,
|
env=env,
|
||||||
cwd=home,
|
cwd=home,
|
||||||
|
preexec_fn=make_preexec_fn(username),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("Failed to spawn PTY for %s", username)
|
log.exception("Failed to spawn PTY for %s", username)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue