local-control 0.1.2__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.
- local_control/__init__.py +11 -0
- local_control/app.py +291 -0
- local_control/auth.py +240 -0
- local_control/cli.py +143 -0
- local_control/clipboard.py +342 -0
- local_control/config.py +47 -0
- local_control/control.py +1043 -0
- local_control/startup.py +140 -0
- local_control/static/css/styles.css +393 -0
- local_control/static/index.html +140 -0
- local_control/static/js/app.js +1658 -0
- local_control/utils/__init__.py +9 -0
- local_control/utils/qrcodegen.py +907 -0
- local_control/utils/terminal_qr.py +34 -0
- local_control-0.1.2.dist-info/METADATA +49 -0
- local_control-0.1.2.dist-info/RECORD +20 -0
- local_control-0.1.2.dist-info/WHEEL +5 -0
- local_control-0.1.2.dist-info/entry_points.txt +2 -0
- local_control-0.1.2.dist-info/licenses/LICENSE +21 -0
- local_control-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local Control package exposing the web server and CLI helpers.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
__all__ = ["create_app", "__version__"]
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
|
|
11
|
+
from .app import create_app # noqa: E402 (lazy import to avoid side effects)
|
local_control/app.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Flask application wiring the authentication manager, control handlers,
|
|
3
|
+
and static frontend.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Callable, Dict, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
from flask import (
|
|
14
|
+
Flask,
|
|
15
|
+
Response,
|
|
16
|
+
jsonify,
|
|
17
|
+
make_response,
|
|
18
|
+
request,
|
|
19
|
+
send_from_directory,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from .auth import AuthManager, CredentialError, RateLimitError
|
|
23
|
+
from . import control, clipboard
|
|
24
|
+
from .clipboard import ClipboardData
|
|
25
|
+
|
|
26
|
+
SESSION_COOKIE = "session_token"
|
|
27
|
+
TRUSTED_COOKIE = "trusted_token"
|
|
28
|
+
SESSION_MAX_AGE = 60 * 60 * 4 # 4 hours
|
|
29
|
+
TRUSTED_MAX_AGE = 60 * 60 * 24 * 30 # 30 days
|
|
30
|
+
|
|
31
|
+
LOG = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def create_app(auth_manager: Optional[AuthManager] = None) -> Flask:
|
|
35
|
+
static_dir = Path(__file__).parent / "static"
|
|
36
|
+
app = Flask(
|
|
37
|
+
__name__,
|
|
38
|
+
static_folder=str(static_dir),
|
|
39
|
+
static_url_path="/static",
|
|
40
|
+
)
|
|
41
|
+
app.config["JSONIFY_PRETTYPRINT_REGULAR"] = False
|
|
42
|
+
auth = auth_manager or AuthManager()
|
|
43
|
+
|
|
44
|
+
def client_key() -> str:
|
|
45
|
+
return request.remote_addr or "unknown"
|
|
46
|
+
|
|
47
|
+
def ensure_session() -> Tuple[Optional[str], Optional[str]]:
|
|
48
|
+
token = request.cookies.get(SESSION_COOKIE)
|
|
49
|
+
user = auth.session_user(token)
|
|
50
|
+
if user:
|
|
51
|
+
return user, None
|
|
52
|
+
|
|
53
|
+
trusted = request.cookies.get(TRUSTED_COOKIE)
|
|
54
|
+
user = auth.auto_login(trusted)
|
|
55
|
+
if user:
|
|
56
|
+
fresh = auth.create_session(user)
|
|
57
|
+
return user, fresh
|
|
58
|
+
return None, None
|
|
59
|
+
|
|
60
|
+
def require_auth() -> Tuple[Optional[str], Optional[str]]:
|
|
61
|
+
user, new_token = ensure_session()
|
|
62
|
+
if not user:
|
|
63
|
+
return None, None
|
|
64
|
+
return user, new_token
|
|
65
|
+
|
|
66
|
+
def build_response(
|
|
67
|
+
payload: Dict[str, Any],
|
|
68
|
+
session_token: Optional[str] = None,
|
|
69
|
+
clear_session: bool = False,
|
|
70
|
+
trusted_token: Optional[str] = None,
|
|
71
|
+
clear_trusted: bool = False,
|
|
72
|
+
status: int = 200,
|
|
73
|
+
) -> Response:
|
|
74
|
+
response = make_response(jsonify(payload), status)
|
|
75
|
+
if session_token:
|
|
76
|
+
response.set_cookie(
|
|
77
|
+
SESSION_COOKIE,
|
|
78
|
+
session_token,
|
|
79
|
+
httponly=True,
|
|
80
|
+
secure=False,
|
|
81
|
+
samesite="Strict",
|
|
82
|
+
max_age=SESSION_MAX_AGE,
|
|
83
|
+
)
|
|
84
|
+
if clear_session:
|
|
85
|
+
response.delete_cookie(SESSION_COOKIE)
|
|
86
|
+
if trusted_token:
|
|
87
|
+
response.set_cookie(
|
|
88
|
+
TRUSTED_COOKIE,
|
|
89
|
+
trusted_token,
|
|
90
|
+
httponly=True,
|
|
91
|
+
secure=False,
|
|
92
|
+
samesite="Strict",
|
|
93
|
+
max_age=TRUSTED_MAX_AGE,
|
|
94
|
+
)
|
|
95
|
+
if clear_trusted:
|
|
96
|
+
response.delete_cookie(TRUSTED_COOKIE)
|
|
97
|
+
return response
|
|
98
|
+
|
|
99
|
+
# Routes -----------------------------------------------------------------
|
|
100
|
+
@app.get("/")
|
|
101
|
+
def index() -> Response:
|
|
102
|
+
return send_from_directory(app.static_folder, "index.html")
|
|
103
|
+
|
|
104
|
+
@app.get("/api/session")
|
|
105
|
+
def session_info() -> Response:
|
|
106
|
+
user, fresh_token = ensure_session()
|
|
107
|
+
if user:
|
|
108
|
+
return build_response(
|
|
109
|
+
{"authenticated": True, "username": user},
|
|
110
|
+
session_token=fresh_token,
|
|
111
|
+
)
|
|
112
|
+
return build_response({"authenticated": False, "username": None})
|
|
113
|
+
|
|
114
|
+
@app.post("/api/login")
|
|
115
|
+
def login() -> Response:
|
|
116
|
+
data = request.get_json(silent=True) or {}
|
|
117
|
+
username = str(data.get("username", "")).strip()
|
|
118
|
+
password = str(data.get("password", ""))
|
|
119
|
+
remember = bool(data.get("remember", False))
|
|
120
|
+
remote = client_key()
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
auth.check_rate_limit(remote)
|
|
124
|
+
except RateLimitError as exc:
|
|
125
|
+
return build_response({"error": str(exc)}, status=429)
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
verified = auth.verify_credentials(username, password)
|
|
129
|
+
except CredentialError as exc:
|
|
130
|
+
LOG.warning("Credential verification error: %s", exc)
|
|
131
|
+
return build_response({"error": str(exc)}, status=400)
|
|
132
|
+
|
|
133
|
+
if not verified:
|
|
134
|
+
auth.register_failure(remote)
|
|
135
|
+
return build_response({"error": "Invalid username or password."}, status=401)
|
|
136
|
+
|
|
137
|
+
auth.register_success(remote)
|
|
138
|
+
session_token = auth.create_session(username)
|
|
139
|
+
trusted_token = auth.create_trusted_token(username) if remember else None
|
|
140
|
+
|
|
141
|
+
payload = {"authenticated": True, "username": username}
|
|
142
|
+
return build_response(
|
|
143
|
+
payload,
|
|
144
|
+
session_token=session_token,
|
|
145
|
+
trusted_token=trusted_token,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
@app.post("/api/logout")
|
|
149
|
+
def logout() -> Response:
|
|
150
|
+
token = request.cookies.get(SESSION_COOKIE)
|
|
151
|
+
if token:
|
|
152
|
+
auth.destroy_session(token)
|
|
153
|
+
return build_response({"authenticated": False}, clear_session=True)
|
|
154
|
+
|
|
155
|
+
def auth_endpoint(handler: Callable[[Dict[str, Any]], Dict[str, Any]]) -> Response:
|
|
156
|
+
user, fresh_token = require_auth()
|
|
157
|
+
if not user:
|
|
158
|
+
return build_response({"error": "Authentication required."}, status=401)
|
|
159
|
+
payload = request.get_json(silent=True) or {}
|
|
160
|
+
try:
|
|
161
|
+
response_payload = handler(payload)
|
|
162
|
+
except ValueError as exc:
|
|
163
|
+
return build_response({"error": str(exc)}, status=400)
|
|
164
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
165
|
+
LOG.exception("Handler error: %s", exc)
|
|
166
|
+
return build_response({"error": str(exc)}, status=500)
|
|
167
|
+
return build_response(response_payload, session_token=fresh_token)
|
|
168
|
+
|
|
169
|
+
@app.post("/api/mouse/move")
|
|
170
|
+
def mouse_move() -> Response:
|
|
171
|
+
def action(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
172
|
+
dx = float(data.get("dx", 0.0))
|
|
173
|
+
dy = float(data.get("dy", 0.0))
|
|
174
|
+
state = control.move_cursor(dx, dy)
|
|
175
|
+
return {"status": "ok", "state": state}
|
|
176
|
+
|
|
177
|
+
return auth_endpoint(action)
|
|
178
|
+
|
|
179
|
+
@app.post("/api/mouse/click")
|
|
180
|
+
def mouse_click() -> Response:
|
|
181
|
+
def action(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
182
|
+
button = str(data.get("button", "left"))
|
|
183
|
+
double = bool(data.get("double", False))
|
|
184
|
+
control.click(button=button, double=double)
|
|
185
|
+
return {"status": "ok"}
|
|
186
|
+
|
|
187
|
+
return auth_endpoint(action)
|
|
188
|
+
|
|
189
|
+
@app.post("/api/mouse/button")
|
|
190
|
+
def mouse_button() -> Response:
|
|
191
|
+
def action(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
192
|
+
button = str(data.get("button", "left"))
|
|
193
|
+
button_action = str(data.get("action", "down"))
|
|
194
|
+
control.button_action(button=button, action=button_action)
|
|
195
|
+
return {"status": "ok"}
|
|
196
|
+
|
|
197
|
+
return auth_endpoint(action)
|
|
198
|
+
|
|
199
|
+
@app.post("/api/mouse/scroll")
|
|
200
|
+
def mouse_scroll() -> Response:
|
|
201
|
+
def action(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
202
|
+
vertical = float(data.get("vertical", 0.0))
|
|
203
|
+
horizontal = float(data.get("horizontal", 0.0))
|
|
204
|
+
control.scroll(vertical=vertical, horizontal=horizontal)
|
|
205
|
+
return {"status": "ok"}
|
|
206
|
+
|
|
207
|
+
return auth_endpoint(action)
|
|
208
|
+
|
|
209
|
+
@app.get("/api/mouse/state")
|
|
210
|
+
def mouse_state() -> Response:
|
|
211
|
+
user, fresh_token = require_auth()
|
|
212
|
+
if not user:
|
|
213
|
+
return build_response({"error": "Authentication required."}, status=401)
|
|
214
|
+
state = control.cursor_state()
|
|
215
|
+
return build_response({"status": "ok", "state": state}, session_token=fresh_token)
|
|
216
|
+
|
|
217
|
+
@app.post("/api/keyboard/type")
|
|
218
|
+
def keyboard_type() -> Response:
|
|
219
|
+
def action(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
220
|
+
text = str(data.get("text", ""))
|
|
221
|
+
control.type_text(text)
|
|
222
|
+
return {"status": "ok"}
|
|
223
|
+
|
|
224
|
+
return auth_endpoint(action)
|
|
225
|
+
|
|
226
|
+
@app.post("/api/keyboard/key")
|
|
227
|
+
def keyboard_key() -> Response:
|
|
228
|
+
def action(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
229
|
+
key = str(data.get("key", "")).lower()
|
|
230
|
+
action_name = str(data.get("action", "press")).lower()
|
|
231
|
+
control.key_action(key, action_name)
|
|
232
|
+
return {"status": "ok"}
|
|
233
|
+
|
|
234
|
+
return auth_endpoint(action)
|
|
235
|
+
|
|
236
|
+
@app.post("/api/system/lock")
|
|
237
|
+
def system_lock() -> Response:
|
|
238
|
+
def action(_: Dict[str, Any]) -> Dict[str, Any]:
|
|
239
|
+
control.lock_screen()
|
|
240
|
+
return {"status": "ok"}
|
|
241
|
+
|
|
242
|
+
return auth_endpoint(action)
|
|
243
|
+
|
|
244
|
+
@app.post("/api/system/unlock")
|
|
245
|
+
def system_unlock() -> Response:
|
|
246
|
+
def action(_: Dict[str, Any]) -> Dict[str, Any]:
|
|
247
|
+
control.unlock_screen()
|
|
248
|
+
return {"status": "ok"}
|
|
249
|
+
|
|
250
|
+
return auth_endpoint(action)
|
|
251
|
+
|
|
252
|
+
@app.post("/api/system/shutdown")
|
|
253
|
+
def system_shutdown() -> Response:
|
|
254
|
+
def action(_: Dict[str, Any]) -> Dict[str, Any]:
|
|
255
|
+
control.shutdown_system()
|
|
256
|
+
return {"status": "ok"}
|
|
257
|
+
|
|
258
|
+
return auth_endpoint(action)
|
|
259
|
+
|
|
260
|
+
@app.get("/api/clipboard")
|
|
261
|
+
def clipboard_read() -> Response:
|
|
262
|
+
user, fresh_token = require_auth()
|
|
263
|
+
if not user:
|
|
264
|
+
return build_response({"error": "Authentication required."}, status=401)
|
|
265
|
+
clip = clipboard.get_clipboard()
|
|
266
|
+
if clip:
|
|
267
|
+
content = {"type": clip.kind, "data": clip.data, "mime": clip.mime}
|
|
268
|
+
else:
|
|
269
|
+
content = None
|
|
270
|
+
return build_response(
|
|
271
|
+
{"status": "ok", "content": content},
|
|
272
|
+
session_token=fresh_token,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
@app.post("/api/clipboard")
|
|
276
|
+
def clipboard_write() -> Response:
|
|
277
|
+
def action(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
278
|
+
clip_type = str(data.get("type", "")).lower()
|
|
279
|
+
if clip_type not in {"text", "image"}:
|
|
280
|
+
raise ValueError("Clipboard type must be 'text' or 'image'.")
|
|
281
|
+
payload = data.get("data")
|
|
282
|
+
if payload is None:
|
|
283
|
+
raise ValueError("Clipboard payload missing.")
|
|
284
|
+
mime = data.get("mime")
|
|
285
|
+
clip = ClipboardData(kind=clip_type, data=str(payload), mime=str(mime) if mime else None)
|
|
286
|
+
clipboard.set_clipboard(clip)
|
|
287
|
+
return {"status": "ok"}
|
|
288
|
+
|
|
289
|
+
return auth_endpoint(action)
|
|
290
|
+
|
|
291
|
+
return app
|
local_control/auth.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication manager handling OS credential checks, sessions, device trust,
|
|
3
|
+
and brute-force protections.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import getpass
|
|
9
|
+
import hashlib
|
|
10
|
+
import secrets
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
from .config import data_dir, load_json, save_json
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RateLimitError(Exception):
|
|
22
|
+
"""Raised when a client exceeds the allowed login attempts."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CredentialError(Exception):
|
|
26
|
+
"""Raised when provided credentials cannot be validated."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Session:
|
|
31
|
+
username: str
|
|
32
|
+
created_at: float
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class TrustedDevice:
|
|
37
|
+
username: str
|
|
38
|
+
token_hash: str
|
|
39
|
+
created_at: float
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AuthManager:
|
|
43
|
+
MAX_ATTEMPTS = 5
|
|
44
|
+
WINDOW_SECONDS = 600
|
|
45
|
+
LOCKOUT_SECONDS = 600
|
|
46
|
+
|
|
47
|
+
def __init__(self) -> None:
|
|
48
|
+
base = data_dir()
|
|
49
|
+
self._secret_path = base / "secret.key"
|
|
50
|
+
self._trusted_path = base / "trusted_devices.json"
|
|
51
|
+
self._secret = self._load_secret()
|
|
52
|
+
self._sessions: Dict[str, Session] = {}
|
|
53
|
+
self._trusted_devices: List[TrustedDevice] = self._load_trusted_devices()
|
|
54
|
+
self._attempts: Dict[str, Dict[str, float]] = {}
|
|
55
|
+
self._current_user = getpass.getuser()
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def current_user(self) -> str:
|
|
59
|
+
return self._current_user
|
|
60
|
+
|
|
61
|
+
# Secret helpers ---------------------------------------------------------
|
|
62
|
+
def _load_secret(self) -> bytes:
|
|
63
|
+
if self._secret_path.exists():
|
|
64
|
+
return self._secret_path.read_bytes()
|
|
65
|
+
|
|
66
|
+
secret = secrets.token_bytes(32)
|
|
67
|
+
self._secret_path.write_bytes(secret)
|
|
68
|
+
return secret
|
|
69
|
+
|
|
70
|
+
# Trusted device persistence ---------------------------------------------
|
|
71
|
+
def _load_trusted_devices(self) -> List[TrustedDevice]:
|
|
72
|
+
data = load_json(
|
|
73
|
+
self._trusted_path,
|
|
74
|
+
default={"devices": []},
|
|
75
|
+
)
|
|
76
|
+
devices: List[TrustedDevice] = []
|
|
77
|
+
for entry in data.get("devices", []):
|
|
78
|
+
try:
|
|
79
|
+
devices.append(
|
|
80
|
+
TrustedDevice(
|
|
81
|
+
username=entry["username"],
|
|
82
|
+
token_hash=entry["token_hash"],
|
|
83
|
+
created_at=float(entry.get("created_at", 0.0)),
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
except KeyError:
|
|
87
|
+
continue
|
|
88
|
+
return devices
|
|
89
|
+
|
|
90
|
+
def _persist_trusted(self) -> None:
|
|
91
|
+
payload = {
|
|
92
|
+
"devices": [
|
|
93
|
+
{
|
|
94
|
+
"username": device.username,
|
|
95
|
+
"token_hash": device.token_hash,
|
|
96
|
+
"created_at": device.created_at,
|
|
97
|
+
}
|
|
98
|
+
for device in self._trusted_devices
|
|
99
|
+
]
|
|
100
|
+
}
|
|
101
|
+
save_json(self._trusted_path, payload)
|
|
102
|
+
|
|
103
|
+
# Rate limiting ----------------------------------------------------------
|
|
104
|
+
def check_rate_limit(self, key: str) -> None:
|
|
105
|
+
entry = self._attempts.get(key)
|
|
106
|
+
now = time.time()
|
|
107
|
+
if not entry:
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
locked_until = entry.get("locked_until", 0.0)
|
|
111
|
+
if locked_until and locked_until > now:
|
|
112
|
+
raise RateLimitError("Too many attempts. Retry later.")
|
|
113
|
+
|
|
114
|
+
timestamps = entry.get("timestamps", [])
|
|
115
|
+
# Drop attempts outside of the time window.
|
|
116
|
+
timestamps = [ts for ts in timestamps if now - ts < self.WINDOW_SECONDS]
|
|
117
|
+
entry["timestamps"] = timestamps
|
|
118
|
+
if len(timestamps) >= self.MAX_ATTEMPTS:
|
|
119
|
+
entry["locked_until"] = now + self.LOCKOUT_SECONDS
|
|
120
|
+
raise RateLimitError("Too many attempts. Retry later.")
|
|
121
|
+
|
|
122
|
+
def register_failure(self, key: str) -> None:
|
|
123
|
+
now = time.time()
|
|
124
|
+
entry = self._attempts.setdefault(
|
|
125
|
+
key, {"timestamps": [], "locked_until": 0.0}
|
|
126
|
+
)
|
|
127
|
+
entry.setdefault("timestamps", []).append(now)
|
|
128
|
+
# Enforce the window retention.
|
|
129
|
+
entry["timestamps"] = [
|
|
130
|
+
ts for ts in entry["timestamps"] if now - ts < self.WINDOW_SECONDS
|
|
131
|
+
]
|
|
132
|
+
if len(entry["timestamps"]) >= self.MAX_ATTEMPTS:
|
|
133
|
+
entry["locked_until"] = now + self.LOCKOUT_SECONDS
|
|
134
|
+
|
|
135
|
+
def register_success(self, key: str) -> None:
|
|
136
|
+
if key in self._attempts:
|
|
137
|
+
del self._attempts[key]
|
|
138
|
+
|
|
139
|
+
# Token helpers ----------------------------------------------------------
|
|
140
|
+
def _hash_token(self, token: str) -> str:
|
|
141
|
+
return hashlib.sha256(self._secret + token.encode("utf-8")).hexdigest()
|
|
142
|
+
|
|
143
|
+
def create_session(self, username: str) -> str:
|
|
144
|
+
token = secrets.token_urlsafe(32)
|
|
145
|
+
token_hash = self._hash_token(token)
|
|
146
|
+
self._sessions[token_hash] = Session(username=username, created_at=time.time())
|
|
147
|
+
return token
|
|
148
|
+
|
|
149
|
+
def destroy_session(self, token: str) -> None:
|
|
150
|
+
token_hash = self._hash_token(token)
|
|
151
|
+
self._sessions.pop(token_hash, None)
|
|
152
|
+
|
|
153
|
+
def session_user(self, token: Optional[str]) -> Optional[str]:
|
|
154
|
+
if not token:
|
|
155
|
+
return None
|
|
156
|
+
token_hash = self._hash_token(token)
|
|
157
|
+
session = self._sessions.get(token_hash)
|
|
158
|
+
if session:
|
|
159
|
+
return session.username
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
# Trusted devices --------------------------------------------------------
|
|
163
|
+
def create_trusted_token(self, username: str) -> str:
|
|
164
|
+
token = secrets.token_urlsafe(32)
|
|
165
|
+
token_hash = self._hash_token(token)
|
|
166
|
+
self._trusted_devices.append(
|
|
167
|
+
TrustedDevice(username=username, token_hash=token_hash, created_at=time.time())
|
|
168
|
+
)
|
|
169
|
+
self._persist_trusted()
|
|
170
|
+
return token
|
|
171
|
+
|
|
172
|
+
def auto_login(self, token: Optional[str]) -> Optional[str]:
|
|
173
|
+
if not token:
|
|
174
|
+
return None
|
|
175
|
+
token_hash = self._hash_token(token)
|
|
176
|
+
for device in self._trusted_devices:
|
|
177
|
+
if device.token_hash == token_hash and device.username == self._current_user:
|
|
178
|
+
return device.username
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
# Credential verification ------------------------------------------------
|
|
182
|
+
def verify_credentials(self, username: str, password: str) -> bool:
|
|
183
|
+
if username != self._current_user:
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
system = sys.platform
|
|
187
|
+
if system.startswith("win"):
|
|
188
|
+
return self._verify_windows(username, password)
|
|
189
|
+
return self._verify_unix(username, password)
|
|
190
|
+
|
|
191
|
+
def _verify_unix(self, username: str, password: str) -> bool:
|
|
192
|
+
# Use sudo in non-interactive mode. Requires the user to be part of sudoers.
|
|
193
|
+
if not password:
|
|
194
|
+
return False
|
|
195
|
+
try:
|
|
196
|
+
subprocess.run(
|
|
197
|
+
["sudo", "-k"],
|
|
198
|
+
stdout=subprocess.DEVNULL,
|
|
199
|
+
stderr=subprocess.DEVNULL,
|
|
200
|
+
check=False,
|
|
201
|
+
)
|
|
202
|
+
proc = subprocess.run(
|
|
203
|
+
["sudo", "-S", "-p", "", "true"],
|
|
204
|
+
input=f"{password}\n".encode("utf-8"),
|
|
205
|
+
stdout=subprocess.DEVNULL,
|
|
206
|
+
stderr=subprocess.PIPE,
|
|
207
|
+
check=False,
|
|
208
|
+
timeout=10,
|
|
209
|
+
)
|
|
210
|
+
return proc.returncode == 0
|
|
211
|
+
except (OSError, subprocess.SubprocessError):
|
|
212
|
+
raise CredentialError(
|
|
213
|
+
"Could not verify credentials on this platform. Ensure sudo is available."
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def _verify_windows(self, username: str, password: str) -> bool:
|
|
217
|
+
if not password:
|
|
218
|
+
return False
|
|
219
|
+
try:
|
|
220
|
+
import ctypes
|
|
221
|
+
from ctypes import wintypes
|
|
222
|
+
except ImportError as exc: # pragma: no cover
|
|
223
|
+
raise CredentialError("Windows credential APIs are unavailable.") from exc
|
|
224
|
+
|
|
225
|
+
LOGON32_LOGON_INTERACTIVE = 2
|
|
226
|
+
LOGON32_PROVIDER_DEFAULT = 0
|
|
227
|
+
|
|
228
|
+
handle = wintypes.HANDLE()
|
|
229
|
+
result = ctypes.windll.advapi32.LogonUserW(
|
|
230
|
+
ctypes.c_wchar_p(username),
|
|
231
|
+
ctypes.c_wchar_p(None),
|
|
232
|
+
ctypes.c_wchar_p(password),
|
|
233
|
+
LOGON32_LOGON_INTERACTIVE,
|
|
234
|
+
LOGON32_PROVIDER_DEFAULT,
|
|
235
|
+
ctypes.byref(handle),
|
|
236
|
+
)
|
|
237
|
+
if result:
|
|
238
|
+
ctypes.windll.kernel32.CloseHandle(handle)
|
|
239
|
+
return True
|
|
240
|
+
return False
|
local_control/cli.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line entry point for launching the local control server.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import socket
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Iterable, List, Optional
|
|
13
|
+
|
|
14
|
+
from .app import create_app
|
|
15
|
+
from .startup import StartupManager, StartupError
|
|
16
|
+
from .utils.terminal_qr import render_text
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
20
|
+
parser = argparse.ArgumentParser(
|
|
21
|
+
description="Start the local Control server to steer this machine remotely.",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--host",
|
|
25
|
+
default="0.0.0.0",
|
|
26
|
+
help="Host/IP to bind (default: 0.0.0.0 for all interfaces).",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--port",
|
|
30
|
+
type=int,
|
|
31
|
+
default=4001,
|
|
32
|
+
help="Port to listen on (default: 4001).",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--debug",
|
|
36
|
+
action="store_true",
|
|
37
|
+
help="Enable Flask debug mode.",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--startup",
|
|
41
|
+
action="store_true",
|
|
42
|
+
help="Register this server to launch automatically for the current user.",
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--startup-cancel",
|
|
46
|
+
action="store_true",
|
|
47
|
+
help="Remove the auto-start registration created with --startup.",
|
|
48
|
+
)
|
|
49
|
+
return parser
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main(argv: Optional[Iterable[str]] = None) -> None:
|
|
53
|
+
parser = build_parser()
|
|
54
|
+
args = parser.parse_args(list(argv) if argv is not None else None)
|
|
55
|
+
|
|
56
|
+
if args.startup and args.startup_cancel:
|
|
57
|
+
parser.error("Choose only one of --startup or --startup-cancel.")
|
|
58
|
+
|
|
59
|
+
if args.startup or args.startup_cancel:
|
|
60
|
+
manager = StartupManager(args.host, args.port, args.debug)
|
|
61
|
+
try:
|
|
62
|
+
if args.startup:
|
|
63
|
+
manager.enable()
|
|
64
|
+
print("Startup entry created. Local Control will launch on login.")
|
|
65
|
+
else:
|
|
66
|
+
manager.disable()
|
|
67
|
+
print("Startup entry removed.")
|
|
68
|
+
except StartupError as exc:
|
|
69
|
+
print(f"Startup configuration failed: {exc}", file=sys.stderr)
|
|
70
|
+
sys.exit(1)
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
logging.basicConfig(level=logging.INFO)
|
|
74
|
+
app = create_app()
|
|
75
|
+
if not args.debug or os.environ.get("WERKZEUG_RUN_MAIN") == "true":
|
|
76
|
+
_display_banner(args.host, args.port)
|
|
77
|
+
app.run(host=args.host, port=args.port, debug=args.debug)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _display_banner(host: str, port: int) -> None:
|
|
81
|
+
urls = _candidate_urls(host, port)
|
|
82
|
+
if not urls:
|
|
83
|
+
return
|
|
84
|
+
primary = next((url for url in urls if not url.startswith("http://127.")), urls[0])
|
|
85
|
+
|
|
86
|
+
print("Local Control server ready. Reach it at:", flush=True)
|
|
87
|
+
for url in urls:
|
|
88
|
+
print(f" • {url}", flush=True)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
qr_text = render_text(primary)
|
|
92
|
+
except Exception as exc: # pragma: no cover - cosmetic
|
|
93
|
+
logging.debug("Failed to render QR code: %s", exc)
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
print("\nScan to connect:", flush=True)
|
|
97
|
+
print(qr_text, flush=True)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _candidate_urls(host: str, port: int) -> List[str]:
|
|
101
|
+
hosts: List[str] = []
|
|
102
|
+
if host in {"0.0.0.0", "::", "", "*"}:
|
|
103
|
+
hosts.extend(_local_ipv4_addresses())
|
|
104
|
+
hosts.append("127.0.0.1")
|
|
105
|
+
hosts.append("localhost")
|
|
106
|
+
else:
|
|
107
|
+
hosts.append(host)
|
|
108
|
+
if host not in {"127.0.0.1", "localhost"}:
|
|
109
|
+
hosts.append("127.0.0.1")
|
|
110
|
+
hosts.append("localhost")
|
|
111
|
+
|
|
112
|
+
normalized = []
|
|
113
|
+
for h in hosts:
|
|
114
|
+
if ":" in h and not h.startswith("["):
|
|
115
|
+
normalized.append(f"http://[{h}]:{port}")
|
|
116
|
+
else:
|
|
117
|
+
normalized.append(f"http://{h}:{port}")
|
|
118
|
+
|
|
119
|
+
seen = set()
|
|
120
|
+
unique_urls = []
|
|
121
|
+
for url in normalized:
|
|
122
|
+
if url not in seen:
|
|
123
|
+
seen.add(url)
|
|
124
|
+
unique_urls.append(url)
|
|
125
|
+
return unique_urls
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _local_ipv4_addresses() -> List[str]:
|
|
129
|
+
candidates = set()
|
|
130
|
+
try:
|
|
131
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
|
132
|
+
sock.connect(("8.8.8.8", 80))
|
|
133
|
+
candidates.add(sock.getsockname()[0])
|
|
134
|
+
except OSError:
|
|
135
|
+
pass
|
|
136
|
+
try:
|
|
137
|
+
hostname = socket.gethostname()
|
|
138
|
+
for addr in socket.gethostbyname_ex(hostname)[2]:
|
|
139
|
+
if addr and addr != "127.0.0.1":
|
|
140
|
+
candidates.add(addr)
|
|
141
|
+
except socket.gaierror:
|
|
142
|
+
pass
|
|
143
|
+
return sorted(candidates)
|