muxplex 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
muxplex/__init__.py ADDED
File without changes
muxplex/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running muxplex as: python -m muxplex"""
2
+
3
+ from muxplex.cli import main
4
+
5
+ main()
muxplex/auth.py ADDED
@@ -0,0 +1,232 @@
1
+ """
2
+ muxplex authentication — password and signing secret file management.
3
+ """
4
+
5
+ import base64
6
+ import hmac
7
+ import logging
8
+ import secrets
9
+ from pathlib import Path
10
+
11
+ from itsdangerous import BadSignature, SignatureExpired, TimestampSigner
12
+ from starlette.middleware.base import BaseHTTPMiddleware
13
+ from starlette.requests import Request
14
+ from starlette.responses import JSONResponse, RedirectResponse, Response
15
+
16
+ _log = logging.getLogger(__name__)
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Config directory
21
+ # ---------------------------------------------------------------------------
22
+
23
+
24
+ def _config_dir() -> Path:
25
+ """Return ~/.config/muxplex, creating it (mode 0700) if needed."""
26
+ d = Path.home() / ".config" / "muxplex"
27
+ d.mkdir(mode=0o700, parents=True, exist_ok=True)
28
+ return d
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Password file management
33
+ # ---------------------------------------------------------------------------
34
+
35
+
36
+ def get_password_path() -> Path:
37
+ """Return the path to the password file: ~/.config/muxplex/password."""
38
+ return Path.home() / ".config" / "muxplex" / "password"
39
+
40
+
41
+ def load_password() -> str | None:
42
+ """Read the password file if it exists, return None otherwise."""
43
+ path = get_password_path()
44
+ if not path.exists():
45
+ return None
46
+ return path.read_text().strip()
47
+
48
+
49
+ def generate_and_save_password() -> str:
50
+ """Generate a random password, write it to the password file (0600), return it."""
51
+ pw = secrets.token_urlsafe(20)
52
+ path = get_password_path()
53
+ _config_dir() # ensures dir exists with mode 0700
54
+ path.write_text(pw + "\n")
55
+ path.chmod(0o600)
56
+ return pw
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Secret (signing key) management
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ def get_secret_path() -> Path:
65
+ """Return the path to the signing secret file: ~/.config/muxplex/secret."""
66
+ return Path.home() / ".config" / "muxplex" / "secret"
67
+
68
+
69
+ def load_or_create_secret() -> str:
70
+ """Load the signing secret from file, or create one if it doesn't exist."""
71
+ path = get_secret_path()
72
+ if path.exists():
73
+ return path.read_text().strip()
74
+ secret = secrets.token_urlsafe(32)
75
+ _config_dir() # ensures dir exists with mode 0700, consistent with generate_and_save_password()
76
+ path.write_text(secret + "\n")
77
+ path.chmod(0o600)
78
+ return secret
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Session cookie signing / verification
83
+ # ---------------------------------------------------------------------------
84
+
85
+
86
+ def create_session_cookie(secret: str, ttl_seconds: int) -> str:
87
+ """Create a signed, timestamped session cookie value."""
88
+ signer = TimestampSigner(secret)
89
+ # ttl_seconds is not used at signing time; the timestamp is embedded in
90
+ # the signed value and checked against ttl_seconds during verification.
91
+ return signer.sign("muxplex-session").decode()
92
+
93
+
94
+ def verify_session_cookie(secret: str, cookie: str, ttl_seconds: int) -> bool:
95
+ """Verify a session cookie's signature and expiry. Returns True/False.
96
+
97
+ ttl_seconds=0 means session cookie — no server-side expiry check.
98
+ """
99
+ signer = TimestampSigner(secret)
100
+ try:
101
+ max_age = ttl_seconds if ttl_seconds > 0 else None
102
+ signer.unsign(cookie, max_age=max_age)
103
+ return True
104
+ except (BadSignature, SignatureExpired):
105
+ return False
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # PAM authentication
110
+ # ---------------------------------------------------------------------------
111
+
112
+
113
+ def pam_available() -> bool:
114
+ """Check whether the python-pam module is importable."""
115
+ try:
116
+ import pam # noqa: F401
117
+
118
+ return True
119
+ except ImportError:
120
+ return False
121
+
122
+
123
+ def authenticate_pam(username: str, password: str) -> bool:
124
+ """Authenticate via PAM. Username must match the running process owner."""
125
+ import os
126
+ import pwd
127
+
128
+ import pam
129
+
130
+ running_user = pwd.getpwuid(os.getuid()).pw_name
131
+ if username != running_user:
132
+ return False
133
+ return pam.authenticate(username, password, service="login")
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # Auth middleware
138
+ # ---------------------------------------------------------------------------
139
+
140
+ # Paths that bypass auth (login page itself, static assets it needs)
141
+ _AUTH_EXEMPT_PATHS = {"/login", "/auth/mode", "/auth/logout", "/api/instance-info"}
142
+
143
+ # File extensions that are always served without auth — the login page needs
144
+ # its own CSS, JS, images, and fonts before the user has a session cookie.
145
+ _STATIC_EXTENSIONS = {
146
+ ".css",
147
+ ".js",
148
+ ".svg",
149
+ ".png",
150
+ ".ico",
151
+ ".woff",
152
+ ".woff2",
153
+ ".ttf",
154
+ ".map",
155
+ }
156
+
157
+ # Socket-level localhost addresses — cannot be forged via HTTP headers
158
+ _LOCALHOST_ADDRS = {"127.0.0.1", "::1"}
159
+
160
+
161
+ class AuthMiddleware(BaseHTTPMiddleware):
162
+ """FastAPI middleware that enforces authentication on non-localhost requests."""
163
+
164
+ def __init__(
165
+ self,
166
+ app,
167
+ auth_mode: str,
168
+ secret: str,
169
+ ttl_seconds: int,
170
+ password: str = "",
171
+ federation_key: str = "",
172
+ ):
173
+ super().__init__(app)
174
+ self.auth_mode = auth_mode
175
+ self.secret = secret
176
+ self.ttl_seconds = ttl_seconds
177
+ self.password = password
178
+ self.federation_key = federation_key
179
+
180
+ async def dispatch(self, request: Request, call_next) -> Response:
181
+ # 1. Localhost bypass — client.host is the socket-level IP and cannot
182
+ # be forged by the client (unlike the HTTP Host header).
183
+ client_host = request.client.host if request.client else ""
184
+ if client_host in _LOCALHOST_ADDRS:
185
+ return await call_next(request)
186
+
187
+ # 2. Exempt paths (login page, auth endpoints)
188
+ if request.url.path in _AUTH_EXEMPT_PATHS:
189
+ return await call_next(request)
190
+
191
+ # 3. Static assets — login page needs its CSS/JS/images before auth
192
+ path = request.url.path
193
+ if any(path.endswith(ext) for ext in _STATIC_EXTENSIONS):
194
+ return await call_next(request)
195
+
196
+ # 4. Valid session cookie
197
+ cookie = request.cookies.get("muxplex_session")
198
+ if cookie and verify_session_cookie(self.secret, cookie, self.ttl_seconds):
199
+ return await call_next(request)
200
+
201
+ # 4a. Bearer token (server-to-server federation)
202
+ auth_header = request.headers.get("authorization", "")
203
+ if self.federation_key and auth_header.lower().startswith("bearer "):
204
+ token = auth_header[7:]
205
+ if hmac.compare_digest(token, self.federation_key):
206
+ return await call_next(request)
207
+ _log.warning("federation: rejected Bearer from %s", client_host)
208
+
209
+ # 5. Authorization: Basic header
210
+ auth_header = request.headers.get("authorization", "")
211
+ if auth_header.lower().startswith("basic "):
212
+ try:
213
+ # Strip "Basic " prefix (6 chars) before base64-decoding
214
+ decoded = base64.b64decode(auth_header[6:]).decode()
215
+ username, _, pw = decoded.partition(":")
216
+ if self._check_credentials(username, pw):
217
+ return await call_next(request)
218
+ except Exception:
219
+ pass
220
+ return JSONResponse({"detail": "Invalid credentials"}, status_code=401)
221
+
222
+ # 6. No auth — redirect browsers, 401 for API clients
223
+ accept = request.headers.get("accept", "")
224
+ if "application/json" in accept:
225
+ return JSONResponse({"detail": "Authentication required"}, status_code=401)
226
+ return RedirectResponse(url="/login", status_code=307)
227
+
228
+ def _check_credentials(self, username: str, password: str) -> bool:
229
+ """Validate credentials against the configured auth mode."""
230
+ if self.auth_mode == "pam":
231
+ return authenticate_pam(username, password)
232
+ return password == self.password
muxplex/bells.py ADDED
@@ -0,0 +1,177 @@
1
+ """
2
+ Bell flag polling and unseen_count tracking for the tmux-web muxplex.
3
+
4
+ Based on spike findings: reading the tmux window_bell_flag does NOT clear it.
5
+ The flag persists until the window is made active inside tmux.
6
+
7
+ In-memory state:
8
+ _bell_seen — tracks whether the bell flag was '1' on the last poll,
9
+ keyed by session_name. Used to detect 0→1 transitions.
10
+
11
+ Public API:
12
+ poll_bell_flag(session_name) → bool
13
+ process_bell_flags(session_names, state) → bool
14
+ should_clear_bell(session_name, state) → bool
15
+ apply_bell_clear_rule(state) → list[str]
16
+ """
17
+
18
+ import time
19
+
20
+ from muxplex.sessions import run_tmux
21
+ from muxplex.state import empty_bell
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # In-memory tracking: session_name → bool (was flag set on last poll?)
25
+ # ---------------------------------------------------------------------------
26
+
27
+ _bell_seen: dict[str, bool] = {}
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # poll_bell_flag
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ async def poll_bell_flag(session_name: str) -> bool:
36
+ """Poll the tmux window_bell_flag for session_name.
37
+
38
+ Calls: tmux display-message -t <name> -p #{window_bell_flag}
39
+
40
+ Returns True if the output is '1', False otherwise (including on errors).
41
+ Note: reading does NOT clear the tmux bell flag.
42
+ """
43
+ try:
44
+ output = await run_tmux(
45
+ "display-message", "-t", session_name, "-p", "#{window_bell_flag}"
46
+ )
47
+ return output.strip() == "1"
48
+ except RuntimeError:
49
+ return False
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # process_bell_flags
54
+ # ---------------------------------------------------------------------------
55
+
56
+
57
+ async def process_bell_flags(session_names: list[str], state: dict) -> bool:
58
+ """Poll bell flags for all sessions and update state accordingly.
59
+
60
+ NOTE: The tmux alert-bell hook (POST /api/sessions/{name}/bell) is the
61
+ primary bell detection mechanism. window_bell_flag is only set when NO
62
+ tmux client is watching the window — with an SSH/WezTerm session attached,
63
+ the flag is never set even though the bell fires. This function serves as
64
+ a fallback for sessions that fired before the coordinator registered the hook.
65
+
66
+ Detects 0→1 transitions using _bell_seen and increments unseen_count.
67
+ Persistent '1' flags (1→1) are not double-counted.
68
+ When flag clears (1→0), _bell_seen is reset so the next '1' counts as
69
+ a new, separate bell event.
70
+
71
+ Ensures the bell sub-dict exists for each session in state.
72
+
73
+ Args:
74
+ session_names: List of session names to poll.
75
+ state: Mutable state dict (modified in-place).
76
+
77
+ Returns:
78
+ True if any bell state changed (new bell detected), False otherwise.
79
+ """
80
+ changed = False
81
+
82
+ for name in session_names:
83
+ # Ensure session entry and bell sub-dict exist
84
+ if name not in state["sessions"]:
85
+ state["sessions"][name] = {}
86
+ if "bell" not in state["sessions"][name]:
87
+ state["sessions"][name]["bell"] = empty_bell()
88
+
89
+ bell = state["sessions"][name]["bell"]
90
+ flag_set = await poll_bell_flag(name)
91
+ previously_seen = _bell_seen.get(name, False)
92
+
93
+ if flag_set and not previously_seen:
94
+ # 0→1 transition: new bell event
95
+ bell["unseen_count"] += 1
96
+ bell["last_fired_at"] = time.time()
97
+ _bell_seen[name] = True
98
+ changed = True
99
+ elif not flag_set and previously_seen:
100
+ # 1→0: flag cleared — reset tracking so next '1' is a new bell
101
+ # Do NOT decrement unseen_count
102
+ _bell_seen[name] = False
103
+
104
+ return changed
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Bell clear rule constants
109
+ # ---------------------------------------------------------------------------
110
+
111
+ _INTERACTION_WINDOW_SECONDS: float = 60.0
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # should_clear_bell
116
+ # ---------------------------------------------------------------------------
117
+
118
+
119
+ def should_clear_bell(session_name: str, state: dict) -> bool:
120
+ """Return True if any connected device qualifies to globally acknowledge bells.
121
+
122
+ A session's bells should be cleared when ANY device satisfies ALL of:
123
+ - viewing_session == session_name
124
+ - view_mode == 'fullscreen'
125
+ - last_interaction_at > now - _INTERACTION_WINDOW_SECONDS
126
+
127
+ Args:
128
+ session_name: Name of the tmux session to check.
129
+ state: Current application state dict.
130
+
131
+ Returns:
132
+ True if at least one device meets all conditions, False otherwise.
133
+ """
134
+ cutoff = time.time() - _INTERACTION_WINDOW_SECONDS
135
+ for device in state["devices"].values():
136
+ if (
137
+ device["viewing_session"] == session_name
138
+ and device["view_mode"] == "fullscreen"
139
+ and device["last_interaction_at"] > cutoff
140
+ ):
141
+ return True
142
+ return False
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # apply_bell_clear_rule
147
+ # ---------------------------------------------------------------------------
148
+
149
+
150
+ def apply_bell_clear_rule(state: dict) -> list[str]:
151
+ """Check every session with unseen_count > 0 against the active-device gate.
152
+
153
+ For each qualifying session (unseen_count > 0 AND should_clear_bell):
154
+ - Resets unseen_count to 0
155
+ - Sets seen_at to now
156
+ - Resets _bell_seen[name] = False
157
+
158
+ Args:
159
+ state: Mutable application state dict (modified in-place).
160
+
161
+ Returns:
162
+ List of session names whose bells were cleared.
163
+ """
164
+ cleared: list[str] = []
165
+ now = time.time()
166
+
167
+ for name, session in state["sessions"].items():
168
+ bell = session.get("bell")
169
+ if bell is None or bell.get("unseen_count", 0) == 0:
170
+ continue
171
+ if should_clear_bell(name, state):
172
+ bell["unseen_count"] = 0
173
+ bell["seen_at"] = now
174
+ _bell_seen[name] = False
175
+ cleared.append(name)
176
+
177
+ return cleared