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 +0 -0
- muxplex/__main__.py +5 -0
- muxplex/auth.py +232 -0
- muxplex/bells.py +177 -0
- muxplex/cli.py +1067 -0
- muxplex/frontend/app.js +2556 -0
- muxplex/frontend/apple-touch-icon.png +0 -0
- muxplex/frontend/favicon-32.png +0 -0
- muxplex/frontend/favicon.ico +0 -0
- muxplex/frontend/index.html +251 -0
- muxplex/frontend/login.html +192 -0
- muxplex/frontend/manifest.json +15 -0
- muxplex/frontend/pwa-192.png +0 -0
- muxplex/frontend/pwa-512.png +0 -0
- muxplex/frontend/style.css +1862 -0
- muxplex/frontend/terminal.js +605 -0
- muxplex/frontend/vendor/addon-image.js +3 -0
- muxplex/frontend/vendor/xterm-addon-fit.js +2 -0
- muxplex/frontend/vendor/xterm-addon-search.js +2 -0
- muxplex/frontend/vendor/xterm-addon-web-links.js +2 -0
- muxplex/frontend/vendor/xterm.css +209 -0
- muxplex/frontend/vendor/xterm.js +2 -0
- muxplex/frontend/wordmark-on-dark.svg +11 -0
- muxplex/main.py +1139 -0
- muxplex/service.py +291 -0
- muxplex/sessions.py +140 -0
- muxplex/settings.py +121 -0
- muxplex/state.py +184 -0
- muxplex/tls.py +380 -0
- muxplex/ttyd.py +224 -0
- muxplex-0.1.0.dist-info/METADATA +396 -0
- muxplex-0.1.0.dist-info/RECORD +34 -0
- muxplex-0.1.0.dist-info/WHEEL +4 -0
- muxplex-0.1.0.dist-info/entry_points.txt +2 -0
muxplex/__init__.py
ADDED
|
File without changes
|
muxplex/__main__.py
ADDED
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
|